glm-coding-router 0.3.0 → 0.4.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 +34 -0
- package/dist/cli.js +9 -0
- package/dist/commands/benchmark.js +252 -0
- package/dist/core/errors.js +6 -0
- package/dist/core/process.js +70 -37
- package/dist/templates/benchmark-tasks.js +70 -0
- package/package.json +47 -47
package/README.md
CHANGED
|
@@ -200,6 +200,39 @@ glm-router delegate backend "Task A" # terminal 1
|
|
|
200
200
|
glm-router delegate tests "Task B" # terminal 2
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
+
## benchmark
|
|
204
|
+
|
|
205
|
+
Measure the Claude Code + GLM stack on built-in coding tasks (spec §54 v0.4).
|
|
206
|
+
Each task runs in a throwaway temp directory: the router writes the task files,
|
|
207
|
+
spawns the standard GLM worker (same env injection, plus `--output-format json`
|
|
208
|
+
to capture the result document), then runs the task's validation command:
|
|
209
|
+
|
|
210
|
+
```powershell
|
|
211
|
+
glm-router benchmark --yes # both built-in tasks, 1 run each
|
|
212
|
+
glm-router benchmark --yes --task fn-reverse --repeat 3
|
|
213
|
+
glm-router benchmark --yes --max-turns 15
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Report (per task × run): **duration**, **GLM calls** (assistant turns),
|
|
217
|
+
**retries** (`-` — not exposed by Claude Code yet), **tokens in/out**,
|
|
218
|
+
**tests** (PASS/FAIL of `node test.js`), **success**, **intervention**
|
|
219
|
+
(`needed` when the run did not self-complete). The full JSON report is always
|
|
220
|
+
saved to `%USERPROFILE%\.glm-coding-router\benchmarks\benchmark-<timestamp>.json`
|
|
221
|
+
and `--json` also prints it.
|
|
222
|
+
|
|
223
|
+
Built-in tasks: `fn-reverse` (implement `reverseWords` until the test passes),
|
|
224
|
+
`fix-bug` (repair an even-length `median` bug).
|
|
225
|
+
|
|
226
|
+
Notes:
|
|
227
|
+
- Benchmarking makes **real GLM API calls** — interactive runs ask for
|
|
228
|
+
confirmation; non-interactive runs require `--yes`.
|
|
229
|
+
- Failed tasks are measurements, not errors: the command exits 0 once the
|
|
230
|
+
suite ran. Missing key/claude or a broken spawn still fail with the usual
|
|
231
|
+
`ERROR [10]/[20]/[40]`.
|
|
232
|
+
- `--stack codex` is recognized but not supported yet (headless Codex
|
|
233
|
+
orchestration isn't drivable today); the harness is stack-shaped so it can
|
|
234
|
+
be added later.
|
|
235
|
+
|
|
203
236
|
## CLI reference
|
|
204
237
|
|
|
205
238
|
```text
|
|
@@ -211,6 +244,7 @@ glm-router key check key configured? from which source?
|
|
|
211
244
|
glm-router config show
|
|
212
245
|
glm-router config set models.main glm-5.3
|
|
213
246
|
glm-router delegate <name> run a GLM worker in an isolated git worktree
|
|
247
|
+
glm-router benchmark measure the Claude+GLM stack on built-in tasks
|
|
214
248
|
glm-router project init CLAUDE.md / AGENTS.md managed blocks (--dry-run supported)
|
|
215
249
|
glm-router project remove
|
|
216
250
|
glm-router skill install optional Codex delegation skill
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { projectRemoveCommand } from "./commands/project-remove.js";
|
|
|
14
14
|
import { skillInstallCommand, skillRemoveCommand } from "./commands/skill.js";
|
|
15
15
|
import { uninstallCommand } from "./commands/uninstall.js";
|
|
16
16
|
import { delegateCommand } from "./commands/delegate.js";
|
|
17
|
+
import { benchmarkCommand } from "./commands/benchmark.js";
|
|
17
18
|
const program = new Command();
|
|
18
19
|
program
|
|
19
20
|
.name("glm-router")
|
|
@@ -94,6 +95,14 @@ program
|
|
|
94
95
|
.option("--profile <name>", "profile overlay (defaults to a profile named <name> if defined)")
|
|
95
96
|
.option("--remove", "remove the worktree after a successful run (branch is always kept)")
|
|
96
97
|
.action((name, prompt, commandOptions) => execute(() => delegateCommand(name, prompt, { ...globalOptions(), ...commandOptions })));
|
|
98
|
+
program
|
|
99
|
+
.command("benchmark")
|
|
100
|
+
.description("measure the Claude+GLM stack on built-in coding tasks (makes real GLM calls)")
|
|
101
|
+
.option("--task <id>", "run only this task (repeatable)", (value, previous) => previous.concat([value]), [])
|
|
102
|
+
.option("--stack <name>", "orchestration stack (default claude; codex not yet supported)")
|
|
103
|
+
.option("--max-turns <n>", "worker --max-turns override", (value) => Number(value))
|
|
104
|
+
.option("--repeat <n>", "run each task N times", (value) => Number(value))
|
|
105
|
+
.action((commandOptions) => execute(() => benchmarkCommand({ ...globalOptions(), ...commandOptions })));
|
|
97
106
|
program
|
|
98
107
|
.command("uninstall")
|
|
99
108
|
.description("guided removal (keeps ZAI_API_KEY by default)")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import prompts from "prompts";
|
|
6
|
+
import { version } from "../core/version.js";
|
|
7
|
+
import { WORKER_TOOLS } from "../bin/glm-worker.js";
|
|
8
|
+
import { loadConfig } from "../core/config.js";
|
|
9
|
+
import { locateClaude } from "../core/claude.js";
|
|
10
|
+
import { createGlmEnv } from "../core/env.js";
|
|
11
|
+
import { Errors } from "../core/errors.js";
|
|
12
|
+
import { configDir } from "../core/paths.js";
|
|
13
|
+
import { spawnAgentCapture } from "../core/process.js";
|
|
14
|
+
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
15
|
+
import { BENCHMARK_TASKS, benchmarkTaskById } from "../templates/benchmark-tasks.js";
|
|
16
|
+
import { emitJson } from "./context.js";
|
|
17
|
+
const KNOWN_STACKS = ["claude", "codex"];
|
|
18
|
+
/**
|
|
19
|
+
* glm-router benchmark (spec §54 v0.4, specs/benchmark.md): run built-in
|
|
20
|
+
* coding tasks through the Claude+GLM worker path and report §54 metrics.
|
|
21
|
+
* Failed tasks are measurements, not CLI errors — exits 0 once the suite ran.
|
|
22
|
+
*/
|
|
23
|
+
export async function benchmarkCommand(options, deps = {}) {
|
|
24
|
+
const stack = options.stack ?? "claude";
|
|
25
|
+
if (stack === "codex") {
|
|
26
|
+
throw Errors.invalidArgs("The codex stack is not supported yet: headless Codex orchestration cannot be driven reliably today.", ["v0.4 ships the harness + claude stack; codex slots in later without redesign."]);
|
|
27
|
+
}
|
|
28
|
+
if (!KNOWN_STACKS.includes(stack)) {
|
|
29
|
+
throw Errors.invalidArgs(`Unknown stack "${stack}".`, [`Known stacks: ${KNOWN_STACKS.join(", ")}`]);
|
|
30
|
+
}
|
|
31
|
+
const ids = options.task && options.task.length > 0 ? options.task : BENCHMARK_TASKS.map((t) => t.id);
|
|
32
|
+
const unknown = ids.filter((id) => !benchmarkTaskById(id));
|
|
33
|
+
if (unknown.length > 0) {
|
|
34
|
+
throw Errors.invalidArgs(`Unknown task id(s): ${unknown.join(", ")}.`, [`Available tasks: ${BENCHMARK_TASKS.map((t) => t.id).join(", ")}`]);
|
|
35
|
+
}
|
|
36
|
+
const tasks = ids.map((id) => benchmarkTaskById(id));
|
|
37
|
+
const repeat = options.repeat ?? 1;
|
|
38
|
+
const maxTurns = options.maxTurns;
|
|
39
|
+
if (!Number.isInteger(repeat) || repeat < 1) {
|
|
40
|
+
throw Errors.invalidArgs(`--repeat expects a positive integer, got "${repeat}".`);
|
|
41
|
+
}
|
|
42
|
+
if (maxTurns !== undefined && (!Number.isInteger(maxTurns) || maxTurns < 1)) {
|
|
43
|
+
throw Errors.invalidArgs(`--max-turns expects a positive integer, got "${maxTurns}".`);
|
|
44
|
+
}
|
|
45
|
+
if (options.dryRun) {
|
|
46
|
+
if (options.json) {
|
|
47
|
+
emitJson({ stack, tasks: ids, repeat, maxTurns: maxTurns ?? "(config default)", dryRun: true });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const lines = [
|
|
51
|
+
`would run stack ${stack}`,
|
|
52
|
+
`would run tasks ${ids.join(", ")}`,
|
|
53
|
+
`would repeat ${repeat}x`,
|
|
54
|
+
`would use maxTurns ${maxTurns ?? "(config default)"}`,
|
|
55
|
+
];
|
|
56
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
const interactive = deps.interactive ?? process.stdin.isTTY === true;
|
|
61
|
+
if (!options.yes) {
|
|
62
|
+
if (!interactive) {
|
|
63
|
+
throw Errors.invalidArgs(`benchmark makes real GLM API calls; confirm with --yes when running non-interactively.`, [` glm-router benchmark --yes`]);
|
|
64
|
+
}
|
|
65
|
+
const confirm = deps.confirm ?? defaultConfirm;
|
|
66
|
+
const ok = await confirm();
|
|
67
|
+
if (!ok) {
|
|
68
|
+
process.stdout.write("Cancelled.\n");
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const home = deps.home ?? os.homedir();
|
|
73
|
+
const env = deps.env ?? process.env;
|
|
74
|
+
const config = loadConfig(home);
|
|
75
|
+
const resolved = resolveZaiApiKey({ env, readUserEnv: deps.readUserEnv });
|
|
76
|
+
if (!resolved) {
|
|
77
|
+
throw Errors.zaiKeyMissing();
|
|
78
|
+
}
|
|
79
|
+
const claudePath = locateClaude(config, env);
|
|
80
|
+
const turns = maxTurns ?? config.worker.maxTurns;
|
|
81
|
+
const now = deps.now ?? Date.now;
|
|
82
|
+
const mkdtemp = deps.mkdtemp ?? (() => fs.mkdtempSync(path.join(os.tmpdir(), "glm-benchmark-")));
|
|
83
|
+
const spawn = deps.spawn ?? spawnAgentCapture;
|
|
84
|
+
const startedAt = new Date(now()).toISOString();
|
|
85
|
+
const metrics = [];
|
|
86
|
+
for (const task of tasks) {
|
|
87
|
+
for (let run = 1; run <= repeat; run++) {
|
|
88
|
+
metrics.push(await runTask(task, run, { turns, claudePath, config, key: resolved.key, env, now, mkdtemp, spawn }));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const report = {
|
|
92
|
+
version,
|
|
93
|
+
stack,
|
|
94
|
+
startedAt,
|
|
95
|
+
finishedAt: new Date(now()).toISOString(),
|
|
96
|
+
maxTurns: turns,
|
|
97
|
+
repeat,
|
|
98
|
+
tasks: metrics,
|
|
99
|
+
};
|
|
100
|
+
const savedPath = saveReport(report, home);
|
|
101
|
+
if (!options.quiet && !options.json) {
|
|
102
|
+
process.stdout.write(`report saved to ${savedPath}\n`);
|
|
103
|
+
}
|
|
104
|
+
if (options.json) {
|
|
105
|
+
emitJson({ ...report, savedPath });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
renderTable(metrics, options);
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
async function runTask(task, run, ctx) {
|
|
113
|
+
const dir = ctx.mkdtemp();
|
|
114
|
+
try {
|
|
115
|
+
for (const [relative, content] of Object.entries(task.files)) {
|
|
116
|
+
const file = path.join(dir, relative);
|
|
117
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
|
+
fs.writeFileSync(file, content, "utf8");
|
|
119
|
+
}
|
|
120
|
+
const args = [
|
|
121
|
+
"-p",
|
|
122
|
+
task.prompt,
|
|
123
|
+
"--max-turns",
|
|
124
|
+
String(ctx.turns),
|
|
125
|
+
"--permission-mode",
|
|
126
|
+
"acceptEdits",
|
|
127
|
+
"--tools",
|
|
128
|
+
WORKER_TOOLS,
|
|
129
|
+
"--output-format",
|
|
130
|
+
"json",
|
|
131
|
+
];
|
|
132
|
+
const childEnv = createGlmEnv(ctx.config, ctx.key, ctx.env);
|
|
133
|
+
const started = ctx.now();
|
|
134
|
+
let captured;
|
|
135
|
+
try {
|
|
136
|
+
captured = await ctx.spawn(ctx.claudePath, { args, cwd: dir, env: childEnv, interactive: false });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
throw Errors.childAgentFailed(error instanceof Error ? error.message : String(error));
|
|
140
|
+
}
|
|
141
|
+
const durationMs = ctx.now() - started;
|
|
142
|
+
const parsed = parseClaudeResult(captured.stdout);
|
|
143
|
+
const validation = await runValidation(task.validate, dir);
|
|
144
|
+
const selfCompleted = captured.code === 0 && parsed?.isError !== true && !isErrorSubtype(parsed?.subtype);
|
|
145
|
+
const success = selfCompleted && validation.pass;
|
|
146
|
+
return {
|
|
147
|
+
task: task.id,
|
|
148
|
+
run,
|
|
149
|
+
durationMs,
|
|
150
|
+
childDurationMs: parsed?.durationMs ?? null,
|
|
151
|
+
workerExit: captured.code,
|
|
152
|
+
glmCalls: parsed?.numTurns ?? null,
|
|
153
|
+
inputTokens: parsed?.usage?.input_tokens ?? null,
|
|
154
|
+
outputTokens: parsed?.usage?.output_tokens ?? null,
|
|
155
|
+
subtype: parsed?.subtype ?? null,
|
|
156
|
+
testsPass: validation.pass,
|
|
157
|
+
testsOutput: validation.output,
|
|
158
|
+
success,
|
|
159
|
+
interventionNeeded: !selfCompleted,
|
|
160
|
+
resultParsed: parsed !== null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Parse the child's `--output-format json` result document (last JSON line wins). */
|
|
168
|
+
export function parseClaudeResult(stdout) {
|
|
169
|
+
const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
170
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(lines[i]);
|
|
173
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
174
|
+
return {
|
|
175
|
+
subtype: typeof parsed.subtype === "string" ? parsed.subtype : undefined,
|
|
176
|
+
isError: typeof parsed.is_error === "boolean" ? parsed.is_error : undefined,
|
|
177
|
+
numTurns: typeof parsed.num_turns === "number" ? parsed.num_turns : undefined,
|
|
178
|
+
durationMs: typeof parsed.duration_ms === "number" ? parsed.duration_ms : undefined,
|
|
179
|
+
usage: typeof parsed.usage === "object" && parsed.usage !== null
|
|
180
|
+
? {
|
|
181
|
+
input_tokens: numberOrUndefined(parsed.usage.input_tokens),
|
|
182
|
+
output_tokens: numberOrUndefined(parsed.usage.output_tokens),
|
|
183
|
+
}
|
|
184
|
+
: undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Not JSON — keep walking backwards.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function numberOrUndefined(value) {
|
|
195
|
+
return typeof value === "number" ? value : undefined;
|
|
196
|
+
}
|
|
197
|
+
function isErrorSubtype(subtype) {
|
|
198
|
+
return subtype !== undefined && subtype.startsWith("error");
|
|
199
|
+
}
|
|
200
|
+
async function runValidation(argv, cwd) {
|
|
201
|
+
const [bin, ...rest] = argv;
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
execFile(bin, rest, { cwd, windowsHide: true, encoding: "utf8", timeout: 30_000 }, (error, stdout, stderr) => {
|
|
204
|
+
const output = `${stdout}${stderr}`.trim();
|
|
205
|
+
resolve({ pass: !error, output: output.length > 0 ? output.slice(-400) : "(no output)" });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function saveReport(report, home) {
|
|
210
|
+
const dir = path.join(configDir(home), "benchmarks");
|
|
211
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
212
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
213
|
+
const file = path.join(dir, `benchmark-${stamp}.json`);
|
|
214
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
215
|
+
fs.writeFileSync(tmp, JSON.stringify(report, null, 2) + "\n", "utf8");
|
|
216
|
+
fs.renameSync(tmp, file);
|
|
217
|
+
return file;
|
|
218
|
+
}
|
|
219
|
+
function renderTable(metrics, options) {
|
|
220
|
+
if (options.quiet)
|
|
221
|
+
return;
|
|
222
|
+
const header = ["task", "run", "duration", "GLM calls", "retries", "tokens i/o", "tests", "success", "intervention"];
|
|
223
|
+
const rows = metrics.map((m) => [
|
|
224
|
+
m.task,
|
|
225
|
+
String(m.run),
|
|
226
|
+
formatMs(m.durationMs),
|
|
227
|
+
m.glmCalls === null ? "-" : String(m.glmCalls),
|
|
228
|
+
"-", // retry count: not exposed by Claude Code (specs/benchmark.md)
|
|
229
|
+
m.inputTokens === null || m.outputTokens === null ? "-" : `${m.inputTokens}/${m.outputTokens}`,
|
|
230
|
+
m.testsPass ? "PASS" : "FAIL",
|
|
231
|
+
m.success ? "yes" : "no",
|
|
232
|
+
m.interventionNeeded ? "needed" : "none",
|
|
233
|
+
]);
|
|
234
|
+
const widths = header.map((_, column) => Math.max(header[column].length, ...rows.map((row) => row[column].length)));
|
|
235
|
+
const line = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ");
|
|
236
|
+
process.stdout.write([line(header), ...rows.map(line)].join("\n") + "\n");
|
|
237
|
+
for (const m of metrics.filter((m) => !m.testsPass)) {
|
|
238
|
+
process.stdout.write(`\n${m.task} (run ${m.run}) validation output:\n${m.testsOutput}\n`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function formatMs(ms) {
|
|
242
|
+
return ms >= 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 100) / 10}s`;
|
|
243
|
+
}
|
|
244
|
+
async function defaultConfirm() {
|
|
245
|
+
const response = await prompts({
|
|
246
|
+
type: "confirm",
|
|
247
|
+
name: "ok",
|
|
248
|
+
message: "Benchmark will make real GLM API calls. Continue?",
|
|
249
|
+
initial: false,
|
|
250
|
+
});
|
|
251
|
+
return response.ok === true;
|
|
252
|
+
}
|
package/dist/core/errors.js
CHANGED
|
@@ -96,6 +96,12 @@ export const Errors = {
|
|
|
96
96
|
],
|
|
97
97
|
exitCode: ExitCode.InvalidArgs,
|
|
98
98
|
}),
|
|
99
|
+
invalidArgs: (detail, hint) => new GlmRouterError({
|
|
100
|
+
name: "INVALID_ARGS",
|
|
101
|
+
message: detail,
|
|
102
|
+
hint: hint && hint.length > 0 ? hint : undefined,
|
|
103
|
+
exitCode: ExitCode.InvalidArgs,
|
|
104
|
+
}),
|
|
99
105
|
gitNotFound: () => new GlmRouterError({
|
|
100
106
|
name: "GIT_NOT_FOUND",
|
|
101
107
|
message: "git was not found on PATH.",
|
package/dist/core/process.js
CHANGED
|
@@ -10,51 +10,84 @@ import { Errors } from "./errors.js";
|
|
|
10
10
|
export function spawnAgent(binPath, options) {
|
|
11
11
|
const { args, cwd, env, interactive = true } = options;
|
|
12
12
|
return new Promise((resolve, reject) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
catch (error) {
|
|
24
|
-
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
const signals = ["SIGINT", "SIGTERM"];
|
|
28
|
-
const handlers = new Map();
|
|
29
|
-
for (const signal of signals) {
|
|
30
|
-
const handler = () => {
|
|
31
|
-
if (child.killed)
|
|
32
|
-
return;
|
|
33
|
-
try {
|
|
34
|
-
child.kill(signal);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
// Child already gone; the exit event settles the promise.
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
handlers.set(signal, handler);
|
|
41
|
-
process.on(signal, handler);
|
|
42
|
-
}
|
|
43
|
-
const cleanup = () => {
|
|
44
|
-
for (const [signal, handler] of handlers) {
|
|
45
|
-
process.removeListener(signal, handler);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
13
|
+
const child = spawn(binPath, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
env,
|
|
16
|
+
stdio: interactive ? "inherit" : ["ignore", "inherit", "inherit"],
|
|
17
|
+
shell: false,
|
|
18
|
+
windowsHide: false,
|
|
19
|
+
});
|
|
20
|
+
const handlers = forwardSignals(child);
|
|
48
21
|
child.on("error", (error) => {
|
|
49
|
-
|
|
22
|
+
removeSignals(handlers);
|
|
50
23
|
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
51
24
|
});
|
|
52
25
|
child.on("exit", (code) => {
|
|
53
|
-
|
|
26
|
+
removeSignals(handlers);
|
|
54
27
|
resolve(code ?? 1);
|
|
55
28
|
});
|
|
56
29
|
});
|
|
57
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Like spawnAgent but pipes stdout/stderr instead of inheriting them and
|
|
33
|
+
* resolves the captured output (specs/benchmark.md) — same no-shell rule and
|
|
34
|
+
* signal forwarding. Used by `benchmark` to parse the child's result JSON.
|
|
35
|
+
*/
|
|
36
|
+
export function spawnAgentCapture(binPath, options) {
|
|
37
|
+
const { args, cwd, env } = options;
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const child = spawn(binPath, args, {
|
|
40
|
+
cwd,
|
|
41
|
+
env,
|
|
42
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
43
|
+
shell: false,
|
|
44
|
+
windowsHide: false,
|
|
45
|
+
});
|
|
46
|
+
let stdout = "";
|
|
47
|
+
let stderr = "";
|
|
48
|
+
child.stdout?.setEncoding("utf8");
|
|
49
|
+
child.stderr?.setEncoding("utf8");
|
|
50
|
+
child.stdout?.on("data", (chunk) => {
|
|
51
|
+
stdout += chunk;
|
|
52
|
+
});
|
|
53
|
+
child.stderr?.on("data", (chunk) => {
|
|
54
|
+
stderr += chunk;
|
|
55
|
+
});
|
|
56
|
+
const handlers = forwardSignals(child);
|
|
57
|
+
child.on("error", (error) => {
|
|
58
|
+
removeSignals(handlers);
|
|
59
|
+
reject(Errors.childAgentFailed(errorMessage(error)));
|
|
60
|
+
});
|
|
61
|
+
child.on("exit", (code) => {
|
|
62
|
+
removeSignals(handlers);
|
|
63
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Install SIGINT/SIGTERM forwarding; returns the handlers for cleanup. */
|
|
68
|
+
function forwardSignals(child) {
|
|
69
|
+
const handlers = new Map();
|
|
70
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
71
|
+
const handler = () => {
|
|
72
|
+
if (child.killed)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
child.kill(signal);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Child already gone; the exit event settles the promise.
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
handlers.set(signal, handler);
|
|
82
|
+
process.on(signal, handler);
|
|
83
|
+
}
|
|
84
|
+
return handlers;
|
|
85
|
+
}
|
|
86
|
+
function removeSignals(handlers) {
|
|
87
|
+
for (const [signal, handler] of handlers) {
|
|
88
|
+
process.removeListener(signal, handler);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
58
91
|
function errorMessage(error) {
|
|
59
92
|
return error instanceof Error ? error.message : String(error);
|
|
60
93
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export const BENCHMARK_TASKS = [
|
|
2
|
+
{
|
|
3
|
+
id: "fn-reverse",
|
|
4
|
+
description: "implement reverseWords from a stub until node test.js passes",
|
|
5
|
+
files: {
|
|
6
|
+
"src/util.js": [
|
|
7
|
+
"// Implement reverseWords(str): reverse the ORDER of the words in str.",
|
|
8
|
+
'// Example: reverseWords("hello world") === "world hello".',
|
|
9
|
+
"// Collapse extra whitespace between words and trim the ends.",
|
|
10
|
+
"function reverseWords(str) {",
|
|
11
|
+
" // TODO: implement",
|
|
12
|
+
"}",
|
|
13
|
+
"",
|
|
14
|
+
"module.exports = { reverseWords };",
|
|
15
|
+
"",
|
|
16
|
+
].join("\n"),
|
|
17
|
+
"test.js": [
|
|
18
|
+
'const assert = require("node:assert");',
|
|
19
|
+
'const { reverseWords } = require("./src/util.js");',
|
|
20
|
+
'assert.strictEqual(reverseWords("hello world"), "world hello");',
|
|
21
|
+
'assert.strictEqual(reverseWords("a"), "a");',
|
|
22
|
+
'assert.strictEqual(reverseWords(" spaced out "), "out spaced");',
|
|
23
|
+
'assert.strictEqual(reverseWords(""), "");',
|
|
24
|
+
'console.log("PASS");',
|
|
25
|
+
"",
|
|
26
|
+
].join("\n"),
|
|
27
|
+
},
|
|
28
|
+
prompt: [
|
|
29
|
+
"Implement reverseWords in src/util.js so that `node test.js` passes.",
|
|
30
|
+
"Words are separated by whitespace; collapse repeats and trim the ends.",
|
|
31
|
+
"Do not modify test.js. Verify by running `node test.js` with Bash.",
|
|
32
|
+
].join(" "),
|
|
33
|
+
validate: ["node", "test.js"],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "fix-bug",
|
|
37
|
+
description: "repair an even-length median bug until node test.js passes",
|
|
38
|
+
files: {
|
|
39
|
+
"stats.js": [
|
|
40
|
+
"// median(values) returns the median of a non-empty list of numbers.",
|
|
41
|
+
"function median(values) {",
|
|
42
|
+
" const sorted = [...values].sort((a, b) => a - b);",
|
|
43
|
+
" const mid = Math.floor(sorted.length / 2);",
|
|
44
|
+
" return sorted[mid]; // BUG: wrong for even-length lists",
|
|
45
|
+
"}",
|
|
46
|
+
"",
|
|
47
|
+
"module.exports = { median };",
|
|
48
|
+
"",
|
|
49
|
+
].join("\n"),
|
|
50
|
+
"test.js": [
|
|
51
|
+
'const assert = require("node:assert");',
|
|
52
|
+
'const { median } = require("./stats.js");',
|
|
53
|
+
"assert.strictEqual(median([3, 1, 2]), 2);",
|
|
54
|
+
"assert.strictEqual(median([4, 1, 3, 2]), 2.5);",
|
|
55
|
+
"assert.strictEqual(median([5]), 5);",
|
|
56
|
+
'console.log("PASS");',
|
|
57
|
+
"",
|
|
58
|
+
].join("\n"),
|
|
59
|
+
},
|
|
60
|
+
prompt: [
|
|
61
|
+
"stats.js has a bug: median() returns the wrong result for even-length lists.",
|
|
62
|
+
"Fix it so that `node test.js` passes (even lists return the average of the two middle values).",
|
|
63
|
+
"Do not modify test.js. Verify by running `node test.js` with Bash.",
|
|
64
|
+
].join(" "),
|
|
65
|
+
validate: ["node", "test.js"],
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
export function benchmarkTaskById(id) {
|
|
69
|
+
return BENCHMARK_TASKS.find((task) => task.id === id);
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "glm-coding-router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "GLM Coding Plan workers for Claude Code and Codex",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "hieu9721",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/hieu9721/GLM-coding-router.git"
|
|
11
|
-
},
|
|
12
|
-
"bin": {
|
|
13
|
-
"glm-router": "./dist/cli.js",
|
|
14
|
-
"glm-chat": "./dist/bin/glm-chat.js",
|
|
15
|
-
"glm-worker": "./dist/bin/glm-worker.js",
|
|
16
|
-
"glm-review": "./dist/bin/glm-review.js",
|
|
17
|
-
"glm-fast": "./dist/bin/glm-fast.js"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist"
|
|
21
|
-
],
|
|
22
|
-
"scripts": {
|
|
23
|
-
"dev": "tsx src/cli.ts",
|
|
24
|
-
"build": "tsc",
|
|
25
|
-
"test": "vitest run",
|
|
26
|
-
"test:watch": "vitest",
|
|
27
|
-
"lint": "eslint src tests",
|
|
28
|
-
"prepublishOnly": "npm run build && npm test"
|
|
29
|
-
},
|
|
30
|
-
"engines": {
|
|
31
|
-
"node": ">=20"
|
|
32
|
-
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"commander": "^15.0.0",
|
|
35
|
-
"prompts": "^2.4.2",
|
|
36
|
-
"zod": "^4.6.5"
|
|
37
|
-
},
|
|
38
|
-
"devDependencies": {
|
|
39
|
-
"@types/node": "^22.20.3",
|
|
40
|
-
"@types/prompts": "^2.4.9",
|
|
41
|
-
"eslint": "^9.39.5",
|
|
42
|
-
"tsx": "^4.23.13",
|
|
43
|
-
"typescript": "^5.9.3",
|
|
44
|
-
"typescript-eslint": "^8.70.0",
|
|
45
|
-
"vitest": "^5.0.1"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "glm-coding-router",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "GLM Coding Plan workers for Claude Code and Codex",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "hieu9721",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/hieu9721/GLM-coding-router.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"glm-router": "./dist/cli.js",
|
|
14
|
+
"glm-chat": "./dist/bin/glm-chat.js",
|
|
15
|
+
"glm-worker": "./dist/bin/glm-worker.js",
|
|
16
|
+
"glm-review": "./dist/bin/glm-review.js",
|
|
17
|
+
"glm-fast": "./dist/bin/glm-fast.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "tsx src/cli.ts",
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"lint": "eslint src tests",
|
|
28
|
+
"prepublishOnly": "npm run build && npm test"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"commander": "^15.0.0",
|
|
35
|
+
"prompts": "^2.4.2",
|
|
36
|
+
"zod": "^4.6.5"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.20.3",
|
|
40
|
+
"@types/prompts": "^2.4.9",
|
|
41
|
+
"eslint": "^9.39.5",
|
|
42
|
+
"tsx": "^4.23.13",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"typescript-eslint": "^8.70.0",
|
|
45
|
+
"vitest": "^5.0.1"
|
|
46
|
+
}
|
|
47
|
+
}
|