glm-coding-router 1.1.1 → 2.0.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 (39) hide show
  1. package/README.md +534 -419
  2. package/dist/bin/glm-review.js +46 -4
  3. package/dist/bin/glm-worker.js +37 -4
  4. package/dist/budget/estimator.js +218 -0
  5. package/dist/budget/manager.js +223 -0
  6. package/dist/cli.js +38 -0
  7. package/dist/commands/benchmark.js +4 -0
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/runs.js +568 -0
  10. package/dist/commands/usage.js +1 -40
  11. package/dist/commands/watch.js +289 -0
  12. package/dist/core/agent-args.js +20 -0
  13. package/dist/core/config.js +61 -0
  14. package/dist/core/errors.js +24 -0
  15. package/dist/core/paths.js +32 -0
  16. package/dist/core/process.js +83 -0
  17. package/dist/core/prompt.js +18 -5
  18. package/dist/core/routing-flags.js +59 -0
  19. package/dist/core/zai-quota.js +46 -0
  20. package/dist/events/bus.js +64 -0
  21. package/dist/events/claude-adapter.js +416 -0
  22. package/dist/events/types.js +9 -0
  23. package/dist/handoff/bundle.js +203 -0
  24. package/dist/handoff/parent-handoff.js +48 -0
  25. package/dist/mcp/server.js +45 -1
  26. package/dist/routing/glm-routing.js +131 -0
  27. package/dist/runs/checkpoint.js +204 -0
  28. package/dist/runs/drain.js +165 -0
  29. package/dist/runs/heartbeat.js +45 -0
  30. package/dist/runs/registry.js +350 -0
  31. package/dist/runs/store.js +186 -0
  32. package/dist/runs/ulid.js +112 -0
  33. package/dist/runs/worker-run.js +672 -0
  34. package/dist/templates/agents-block.js +9 -0
  35. package/dist/templates/claude-block.js +9 -0
  36. package/dist/templates/glm-delegation-skill.js +76 -65
  37. package/dist/tui/progress.js +338 -0
  38. package/dist/tui/render.js +78 -0
  39. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Arguments shared by every headless agent we spawn
3
+ * (specs/review-mcp-isolation.md).
4
+ */
5
+ /**
6
+ * Keep the user's MCP servers out of the children we construct.
7
+ *
8
+ * `--tools` only restricts Claude Code's **built-in** set, so MCP tools
9
+ * registered at user/project scope are additive and survive it. With our own
10
+ * server registered (`glm-router mcp install`), that handed every `glm-review`
11
+ * session an `mcp__glm-coding-router__glm_worker` — write access and recursion
12
+ * from a surface documented as read-only.
13
+ *
14
+ * `--strict-mcp-config` uses only the servers given by `--mcp-config`. We pass
15
+ * none, so the set is empty and the child's tool surface is exactly the one we
16
+ * asked for. Security setting: not configurable, not overridable by a profile.
17
+ * Interactive sessions (`glm-chat`, `glm-fast`) are deliberately excluded —
18
+ * the user's own servers are theirs.
19
+ */
20
+ export const STRICT_MCP_ARGS = ["--strict-mcp-config"];
@@ -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}).`,
@@ -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
+ }
@@ -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
+ }