glm-coding-router 0.2.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/LICENSE +21 -21
- package/README.md +360 -286
- package/dist/cli.js +16 -0
- package/dist/commands/benchmark.js +252 -0
- package/dist/commands/delegate.js +134 -0
- package/dist/core/errors.js +30 -0
- package/dist/core/git.js +31 -0
- package/dist/core/process.js +70 -37
- package/dist/core/worktree.js +118 -0
- package/dist/templates/agents-block.js +44 -44
- package/dist/templates/benchmark-tasks.js +70 -0
- package/dist/templates/claude-block.js +47 -47
- package/dist/templates/glm-delegation-skill.js +65 -65
- package/package.json +47 -47
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,8 @@ import { projectInitCommand } from "./commands/project-init.js";
|
|
|
13
13
|
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
|
+
import { delegateCommand } from "./commands/delegate.js";
|
|
17
|
+
import { benchmarkCommand } from "./commands/benchmark.js";
|
|
16
18
|
const program = new Command();
|
|
17
19
|
program
|
|
18
20
|
.name("glm-router")
|
|
@@ -87,6 +89,20 @@ skill
|
|
|
87
89
|
.command("remove")
|
|
88
90
|
.description("remove the glm-delegation skill")
|
|
89
91
|
.action(() => execute(() => Promise.resolve(skillRemoveCommand(globalOptions()))));
|
|
92
|
+
program
|
|
93
|
+
.command("delegate <name> [prompt...]")
|
|
94
|
+
.description("run a GLM worker in an isolated git worktree (branch glm/delegate/<name>)")
|
|
95
|
+
.option("--profile <name>", "profile overlay (defaults to a profile named <name> if defined)")
|
|
96
|
+
.option("--remove", "remove the worktree after a successful run (branch is always kept)")
|
|
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 })));
|
|
90
106
|
program
|
|
91
107
|
.command("uninstall")
|
|
92
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
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { loadConfig } from "../core/config.js";
|
|
3
|
+
import { locateClaude } from "../core/claude.js";
|
|
4
|
+
import { createGlmEnv } from "../core/env.js";
|
|
5
|
+
import { Errors } from "../core/errors.js";
|
|
6
|
+
import { gitTopLevel } from "../core/git.js";
|
|
7
|
+
import { logger, redact } from "../core/logging.js";
|
|
8
|
+
import { spawnAgent } from "../core/process.js";
|
|
9
|
+
import { applyProfile } from "../core/profile.js";
|
|
10
|
+
import { readStdin, resolvePrompt } from "../core/prompt.js";
|
|
11
|
+
import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
12
|
+
import { createDelegateWorktree, delegateBranch, delegateWorktreePath, removeDelegateWorktree, rollbackDelegateBranch, validateDelegateName, } from "../core/worktree.js";
|
|
13
|
+
import { buildWorkerArgs } from "../bin/glm-worker.js";
|
|
14
|
+
import { emitJson } from "./context.js";
|
|
15
|
+
/** A profile literally named after the delegate applies unless --profile says otherwise. */
|
|
16
|
+
function resolveProfileName(name, options, config) {
|
|
17
|
+
if (options.profile) {
|
|
18
|
+
return options.profile;
|
|
19
|
+
}
|
|
20
|
+
return config.profiles[name] ? name : undefined;
|
|
21
|
+
}
|
|
22
|
+
function banner(text, quiet) {
|
|
23
|
+
if (!quiet) {
|
|
24
|
+
process.stdout.write(`${text}\n`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* glm-router delegate (spec §54 v0.3, specs/delegate-worktrees.md): run a GLM
|
|
29
|
+
* worker in an isolated git worktree. Worktree + branch are kept after the run
|
|
30
|
+
* (no automatic git commits); --remove drops the worktree after success only.
|
|
31
|
+
*/
|
|
32
|
+
export async function delegateCommand(name, promptArgs, options, deps = {}) {
|
|
33
|
+
validateDelegateName(name);
|
|
34
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
35
|
+
const env = deps.env ?? process.env;
|
|
36
|
+
const runGit = deps.runGit;
|
|
37
|
+
const repoRoot = await gitTopLevel(cwd, runGit);
|
|
38
|
+
if (!repoRoot) {
|
|
39
|
+
throw Errors.gitRepoRequired(cwd);
|
|
40
|
+
}
|
|
41
|
+
const home = deps.home ?? os.homedir();
|
|
42
|
+
const baseConfig = loadConfig(home);
|
|
43
|
+
const profileName = resolveProfileName(name, options, baseConfig);
|
|
44
|
+
const config = applyProfile(baseConfig, profileName);
|
|
45
|
+
const resolved = resolveZaiApiKey({ env, readUserEnv: deps.readUserEnv });
|
|
46
|
+
if (!resolved) {
|
|
47
|
+
throw Errors.zaiKeyMissing();
|
|
48
|
+
}
|
|
49
|
+
const claudePath = locateClaude(config, env);
|
|
50
|
+
const prompt = await resolvePrompt(promptArgs, deps.readStdinFn ?? readStdin, "glm-router delegate");
|
|
51
|
+
const branch = delegateBranch(name);
|
|
52
|
+
const worktreePath = delegateWorktreePath(repoRoot, name);
|
|
53
|
+
if (options.dryRun) {
|
|
54
|
+
if (options.json) {
|
|
55
|
+
emitJson({ name, profile: profileName ?? null, worktree: worktreePath, branch, dryRun: true });
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
banner(`would create worktree ${worktreePath}`, options.quiet);
|
|
59
|
+
banner(`would create branch ${branch} (from HEAD)`, options.quiet);
|
|
60
|
+
banner(`would run glm-worker in the worktree with the given prompt`, options.quiet);
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
// Collision checks run inside createDelegateWorktree before anything is written.
|
|
65
|
+
const createdPath = await createDelegateWorktree(repoRoot, name, { runGit });
|
|
66
|
+
if (options.json) {
|
|
67
|
+
emitJson({ name, profile: profileName ?? null, worktree: createdPath, branch });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
banner(`[glm-router] delegate ${name}`, options.quiet);
|
|
71
|
+
banner(`[glm-router] worktree ${createdPath}`, options.quiet);
|
|
72
|
+
banner(`[glm-router] branch ${branch}`, options.quiet);
|
|
73
|
+
if (profileName) {
|
|
74
|
+
banner(`[glm-router] profile ${profileName}`, options.quiet);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const args = buildWorkerArgs(prompt, config);
|
|
78
|
+
const childEnv = createGlmEnv(config, resolved.key, env);
|
|
79
|
+
logger.debug(redact(`spawning ${claudePath} in ${createdPath}`, [resolved.key]));
|
|
80
|
+
const spawn = deps.spawn ?? ((binPath, spawnOptions) => spawnAgent(binPath, {
|
|
81
|
+
args: [...spawnOptions.args],
|
|
82
|
+
cwd: spawnOptions.cwd,
|
|
83
|
+
env: spawnOptions.env,
|
|
84
|
+
interactive: false,
|
|
85
|
+
}));
|
|
86
|
+
let exitCode;
|
|
87
|
+
try {
|
|
88
|
+
exitCode = await spawn(claudePath, { args, cwd: createdPath, env: childEnv, interactive: false });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
// Worker never started: roll back the pristine worktree and its branch so
|
|
92
|
+
// the same delegate name can simply be re-run.
|
|
93
|
+
const worktreeComplaint = await removeDelegateWorktree(repoRoot, createdPath, { runGit });
|
|
94
|
+
if (worktreeComplaint) {
|
|
95
|
+
logger.debug(`worktree kept after spawn failure: ${worktreeComplaint}`);
|
|
96
|
+
}
|
|
97
|
+
const branchComplaint = await rollbackDelegateBranch(repoRoot, branch, { runGit });
|
|
98
|
+
if (branchComplaint) {
|
|
99
|
+
logger.debug(`branch kept after spawn failure: ${branchComplaint}`);
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
let removed = false;
|
|
104
|
+
if (options.remove && exitCode === 0) {
|
|
105
|
+
const complaint = await removeDelegateWorktree(repoRoot, createdPath, { runGit });
|
|
106
|
+
if (complaint) {
|
|
107
|
+
removed = false;
|
|
108
|
+
const message = `git refused to remove the worktree (it may contain uncommitted work):\n ${complaint.trim().split("\n").join("\n ")}`;
|
|
109
|
+
if (options.json) {
|
|
110
|
+
emitJson({ name, exitCode, worktree: createdPath, branch, removed: false, note: message });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
banner(`[glm-router] ${message}`, options.quiet);
|
|
114
|
+
banner(`[glm-router] worktree kept at ${createdPath}`, options.quiet);
|
|
115
|
+
}
|
|
116
|
+
return exitCode;
|
|
117
|
+
}
|
|
118
|
+
removed = true;
|
|
119
|
+
}
|
|
120
|
+
if (options.json) {
|
|
121
|
+
emitJson({ name, exitCode, worktree: createdPath, branch, removed });
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
banner(`[glm-router] worker exited ${exitCode}`, options.quiet);
|
|
125
|
+
if (removed) {
|
|
126
|
+
banner(`[glm-router] worktree removed; branch ${branch} kept`, options.quiet);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
banner(`[glm-router] worktree kept at ${createdPath}`, options.quiet);
|
|
130
|
+
banner(`[glm-router] next: inspect it, then merge ${branch} (or discard with git worktree remove)`, options.quiet);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return exitCode;
|
|
134
|
+
}
|
package/dist/core/errors.js
CHANGED
|
@@ -96,6 +96,36 @@ 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
|
+
}),
|
|
105
|
+
gitNotFound: () => new GlmRouterError({
|
|
106
|
+
name: "GIT_NOT_FOUND",
|
|
107
|
+
message: "git was not found on PATH.",
|
|
108
|
+
hint: ["delegate needs git for worktree isolation.", "", "Install Git for Windows: https://git-scm.com/download/win"],
|
|
109
|
+
exitCode: ExitCode.ProjectRootNotFound,
|
|
110
|
+
}),
|
|
111
|
+
gitRepoRequired: (cwd) => new GlmRouterError({
|
|
112
|
+
name: "GIT_REPO_REQUIRED",
|
|
113
|
+
message: `Not inside a git repository (cwd: ${cwd}).`,
|
|
114
|
+
hint: ["delegate runs each worker in a git worktree and needs a repo root.", "", "Run it from inside the project's git repository, or create one:", "", " git init"],
|
|
115
|
+
exitCode: ExitCode.ProjectRootNotFound,
|
|
116
|
+
}),
|
|
117
|
+
worktreeFailed: (operation, cause, hint) => new GlmRouterError({
|
|
118
|
+
name: "WORKTREE_FAILED",
|
|
119
|
+
message: `git ${operation} failed: ${cause.trim() || "unknown git error"}`,
|
|
120
|
+
hint: hint ?? ["Fix the state git describes above, then re-run the delegate command."],
|
|
121
|
+
exitCode: ExitCode.ManagedFileWriteFailed,
|
|
122
|
+
}),
|
|
123
|
+
invalidDelegateName: (name) => new GlmRouterError({
|
|
124
|
+
name: "INVALID_DELEGATE_NAME",
|
|
125
|
+
message: `"${name}" is not a valid delegate name.`,
|
|
126
|
+
hint: ["Use letters, digits, dots, dashes, underscores; start with a letter or digit.", "", "Examples: backend, auth-refresh, tests.v2"],
|
|
127
|
+
exitCode: ExitCode.InvalidArgs,
|
|
128
|
+
}),
|
|
99
129
|
managedBlockCorrupt: (file, cause) => new GlmRouterError({
|
|
100
130
|
name: "MANAGED_BLOCK_CORRUPT",
|
|
101
131
|
message: `Managed block in ${file} is malformed: ${cause}`,
|
package/dist/core/git.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Errors } from "./errors.js";
|
|
4
|
+
function isENOENT(error) {
|
|
5
|
+
return error !== null && typeof error === "object" && error.code === "ENOENT";
|
|
6
|
+
}
|
|
7
|
+
/** Real git runner: resolves with the exit code instead of throwing on failure. */
|
|
8
|
+
export const runGit = (args, cwd) => new Promise((resolve, reject) => {
|
|
9
|
+
execFile("git", args, { cwd, windowsHide: true, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
10
|
+
if (error && isENOENT(error)) {
|
|
11
|
+
reject(Errors.gitNotFound());
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const code = error && typeof error.code === "number"
|
|
15
|
+
? error.code
|
|
16
|
+
: 0;
|
|
17
|
+
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Strict repo-root lookup for delegate (specs/delegate-worktrees.md):
|
|
22
|
+
* unlike findProjectRoot, undefined when cwd is not inside a git repo.
|
|
23
|
+
*/
|
|
24
|
+
export async function gitTopLevel(cwd, run = runGit) {
|
|
25
|
+
const result = await run(["rev-parse", "--show-toplevel"], cwd);
|
|
26
|
+
if (result.code !== 0) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const root = result.stdout.trim();
|
|
30
|
+
return root.length > 0 ? path.resolve(root) : undefined;
|
|
31
|
+
}
|
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
|
}
|