glm-coding-router 1.1.2 → 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.
- package/README.md +113 -5
- package/dist/bin/glm-review.js +28 -3
- package/dist/bin/glm-worker.js +30 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +38 -0
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/usage.js +1 -40
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/paths.js +32 -0
- package/dist/core/process.js +83 -0
- package/dist/core/prompt.js +18 -5
- package/dist/core/routing-flags.js +59 -0
- package/dist/core/zai-quota.js +46 -0
- package/dist/events/bus.js +64 -0
- package/dist/events/claude-adapter.js +416 -0
- package/dist/events/types.js +9 -0
- package/dist/handoff/bundle.js +203 -0
- package/dist/handoff/parent-handoff.js +48 -0
- package/dist/mcp/server.js +45 -1
- package/dist/routing/glm-routing.js +131 -0
- package/dist/runs/checkpoint.js +204 -0
- package/dist/runs/drain.js +165 -0
- package/dist/runs/heartbeat.js +45 -0
- package/dist/runs/registry.js +350 -0
- package/dist/runs/store.js +186 -0
- package/dist/runs/ulid.js +112 -0
- package/dist/runs/worker-run.js +672 -0
- package/dist/templates/agents-block.js +9 -0
- package/dist/templates/claude-block.js +9 -0
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +78 -0
- 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
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
/**
|
package/dist/core/errors.js
CHANGED
|
@@ -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}).`,
|
package/dist/core/paths.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/process.js
CHANGED
|
@@ -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();
|
package/dist/core/prompt.js
CHANGED
|
@@ -17,16 +17,29 @@ export function readStdin() {
|
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* Resolve the task prompt (spec §15, §40):
|
|
20
|
-
*
|
|
20
|
+
* arguments → stdin 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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/** Fetch and validate the Z.ai quota snapshot. Never logs the Authorization header. */
|
|
20
|
+
export async function fetchZaiQuota(key, fetchImpl) {
|
|
21
|
+
let response;
|
|
22
|
+
try {
|
|
23
|
+
response = await fetchImpl(ZAI_QUOTA_URL, {
|
|
24
|
+
method: "GET",
|
|
25
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
26
|
+
signal: AbortSignal.timeout(10_000),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
throw new Error(`Z.ai monitor endpoint unreachable (${error instanceof Error ? error.message : "network error"})`);
|
|
31
|
+
}
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(`Z.ai monitor endpoint returned HTTP ${response.status}`);
|
|
34
|
+
}
|
|
35
|
+
let body;
|
|
36
|
+
try {
|
|
37
|
+
body = (await response.json());
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error("Z.ai monitor endpoint returned a non-JSON body");
|
|
41
|
+
}
|
|
42
|
+
if (body.code !== 200 || typeof body.data !== "object" || body.data === null) {
|
|
43
|
+
throw new Error(`Z.ai monitor endpoint rejected the request (${body.msg ?? `code ${String(body.code)}`})`);
|
|
44
|
+
}
|
|
45
|
+
return body.data;
|
|
46
|
+
}
|