glm-coding-router 1.1.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +542 -426
- 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 +46 -3
- package/dist/commands/dashboard.js +348 -0
- package/dist/commands/doctor-auth.js +107 -0
- package/dist/commands/doctor-command.js +171 -41
- package/dist/commands/landing.js +47 -0
- package/dist/commands/runs.js +568 -0
- package/dist/commands/status.js +28 -15
- package/dist/commands/usage.js +34 -58
- package/dist/commands/watch.js +289 -0
- package/dist/core/config.js +61 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/key-inspector.js +45 -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/user-env.js +17 -7
- package/dist/core/zai-quota.js +148 -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 +53 -44
- package/dist/templates/claude-block.js +56 -47
- package/dist/templates/glm-delegation-skill.js +76 -65
- package/dist/tui/command-ui.js +158 -0
- package/dist/tui/progress.js +338 -0
- package/dist/tui/render.js +144 -0
- package/package.json +1 -1
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadConfig } from "../core/config.js";
|
|
5
|
+
import { Errors, ExitCode } from "../core/errors.js";
|
|
6
|
+
import { logger } from "../core/logging.js";
|
|
7
|
+
import { activeRunFile, runDir } from "../core/paths.js";
|
|
8
|
+
import { finishRun, isOrphaned, listActive, listHistory, pruneHistory, } from "../runs/registry.js";
|
|
9
|
+
import { eventsFilePath, readEvents, summarize } from "../runs/store.js";
|
|
10
|
+
import { emitJson } from "./context.js";
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
/** A table is a summary view; the 20 newest runs fit a screen and a question. */
|
|
13
|
+
const DEFAULT_LIMIT = 20;
|
|
14
|
+
/**
|
|
15
|
+
* Matches the rich progress renderer's tool column width so a replayed tree and
|
|
16
|
+
* the live view print identical lines — the two views must not drift apart.
|
|
17
|
+
*/
|
|
18
|
+
const TOOL_COLUMN_WIDTH = 5;
|
|
19
|
+
const TABLE_HEADERS = ["id", "state", "kind", "model", "started", "duration", "turns", "files", "cwd"];
|
|
20
|
+
/**
|
|
21
|
+
* glm-router runs — the readable face of the history Phase B records. Exit 0
|
|
22
|
+
* even with an empty history: having no runs yet is a normal state, not an
|
|
23
|
+
* error, so the command never trains users to ignore its exit code.
|
|
24
|
+
*/
|
|
25
|
+
export function runsCommand(options, deps = {}) {
|
|
26
|
+
const home = deps.home ?? os.homedir();
|
|
27
|
+
const now = deps.now ?? (() => new Date());
|
|
28
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
29
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
30
|
+
throw Errors.invalidArgs(`--limit expects a positive integer, got "${String(options.limit)}"`);
|
|
31
|
+
}
|
|
32
|
+
const rows = options.active ? activeRows(home, now, limit) : historyRows(home, limit);
|
|
33
|
+
if (options.json) {
|
|
34
|
+
// RunRow already IS the machine shape: full ids, full cwd paths, nulls
|
|
35
|
+
// where the table shows a placeholder.
|
|
36
|
+
emitJson(rows);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (rows.length === 0) {
|
|
40
|
+
process.stdout.write(options.active ? "no active runs\n" : "no runs recorded yet\n");
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
process.stdout.write(renderTable(TABLE_HEADERS, rows.map(rowToCells)) + "\n");
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* History rows: the registry's refs plus the model/cwd only `events.jsonl`
|
|
48
|
+
* knows. A run that is still going appears here too — its directory exists
|
|
49
|
+
* from the first event — and would read as CRASHED, because its stream has
|
|
50
|
+
* no terminal event yet. The active file is the live truth, so its state
|
|
51
|
+
* overrides the derived one.
|
|
52
|
+
*/
|
|
53
|
+
function historyRows(home, limit) {
|
|
54
|
+
const live = new Map(listActive(home).map((run) => [run.id, run.state]));
|
|
55
|
+
return listHistory(home)
|
|
56
|
+
.slice(0, limit)
|
|
57
|
+
.map((ref) => {
|
|
58
|
+
const start = readRunStartInfo(runDir(home, ref.date, ref.id));
|
|
59
|
+
return {
|
|
60
|
+
id: ref.id,
|
|
61
|
+
date: ref.date,
|
|
62
|
+
state: live.get(ref.id) ?? ref.state,
|
|
63
|
+
kind: ref.kind,
|
|
64
|
+
model: start.model,
|
|
65
|
+
startedAt: ref.startedAt,
|
|
66
|
+
durationMs: ref.summary?.durationMs ?? null,
|
|
67
|
+
turns: ref.summary?.turns ?? null,
|
|
68
|
+
files: ref.summary?.filesChanged.length ?? null,
|
|
69
|
+
routingAdvice: ref.summary?.routingAdvice ?? null,
|
|
70
|
+
cwd: start.cwd,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** Active rows: state from the active file, counters rebuilt from the stream so far. */
|
|
75
|
+
function activeRows(home, now, limit) {
|
|
76
|
+
return listActive(home)
|
|
77
|
+
.slice(0, limit)
|
|
78
|
+
.map((run) => {
|
|
79
|
+
const dir = runDir(home, run.date, run.id);
|
|
80
|
+
// The active registry entry deliberately carries no counters — turns and
|
|
81
|
+
// files are whatever the run has written so far, rebuilt from events.
|
|
82
|
+
const events = readEvents(dir);
|
|
83
|
+
const progress = events.length > 0 ? summarize(events) : null;
|
|
84
|
+
return {
|
|
85
|
+
id: run.id,
|
|
86
|
+
date: run.date,
|
|
87
|
+
state: run.state,
|
|
88
|
+
kind: run.kind,
|
|
89
|
+
model: run.model,
|
|
90
|
+
startedAt: run.startedAt,
|
|
91
|
+
durationMs: elapsedSince(run.startedAt, now),
|
|
92
|
+
turns: progress?.turns ?? null,
|
|
93
|
+
files: progress?.filesChanged.length ?? null,
|
|
94
|
+
routingAdvice: null, // a live run has no summary yet
|
|
95
|
+
cwd: run.cwd,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function rowToCells(row) {
|
|
100
|
+
return [
|
|
101
|
+
row.id.slice(-6),
|
|
102
|
+
row.state,
|
|
103
|
+
row.kind ?? "—",
|
|
104
|
+
row.model ?? "—",
|
|
105
|
+
row.startedAt !== null ? formatLocalTime(row.startedAt) : "—",
|
|
106
|
+
row.durationMs !== null ? formatDuration(row.durationMs) : "—",
|
|
107
|
+
row.turns !== null ? String(row.turns) : "—",
|
|
108
|
+
row.files !== null ? String(row.files) : "—",
|
|
109
|
+
row.cwd !== null ? path.basename(row.cwd) || row.cwd : "—",
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* glm-router runs show <id> — metadata, summary numbers and the per-turn tool
|
|
114
|
+
* tree of one run. The id may be a unique suffix so the short id from the table
|
|
115
|
+
* can be pasted straight back in. This is the command a crashed run exists for:
|
|
116
|
+
* when `summary.json` is missing the numbers are rebuilt from `events.jsonl`.
|
|
117
|
+
*/
|
|
118
|
+
export function runsShowCommand(id, options, deps = {}) {
|
|
119
|
+
const home = deps.home ?? os.homedir();
|
|
120
|
+
const now = deps.now ?? (() => new Date());
|
|
121
|
+
const run = resolveRun(home, id);
|
|
122
|
+
const events = readEvents(run.dir);
|
|
123
|
+
const started = events.find((event) => event.type === "RunStarted") ?? null;
|
|
124
|
+
// summary.json is authoritative (A0: its numbers come from the result
|
|
125
|
+
// message); rebuilding from events is the crashed-run fallback.
|
|
126
|
+
const summary = run.ref?.summary ?? (events.length > 0 ? summarize(events) : null);
|
|
127
|
+
const state = run.active?.state ?? run.ref?.state ?? "CRASHED";
|
|
128
|
+
const kind = started?.kind ?? run.active?.kind ?? run.ref?.kind ?? null;
|
|
129
|
+
const role = started?.role ?? run.active?.role ?? null;
|
|
130
|
+
const model = started?.model ?? run.active?.model ?? null;
|
|
131
|
+
const provider = started?.provider ?? run.active?.provider ?? null;
|
|
132
|
+
const cwd = started?.cwd ?? run.active?.cwd ?? null;
|
|
133
|
+
const startedAt = started?.ts ?? run.active?.startedAt ?? run.ref?.startedAt ?? null;
|
|
134
|
+
const taskTitle = started?.taskTitle ?? run.active?.taskTitle ?? null;
|
|
135
|
+
// A live run's duration keeps growing; a finished run's is whatever it recorded.
|
|
136
|
+
const durationMs = run.active !== null ? elapsedSince(run.active.startedAt, now) : summary?.durationMs ?? null;
|
|
137
|
+
// Phase F writes checkpoint.json; before it lands, absence is the normal case
|
|
138
|
+
// and must render as nothing rather than a gap.
|
|
139
|
+
const checkpoint = fs.existsSync(path.join(run.dir, "checkpoint.json"))
|
|
140
|
+
? path.join(run.dir, "checkpoint.json")
|
|
141
|
+
: null;
|
|
142
|
+
if (options.json) {
|
|
143
|
+
emitJson({
|
|
144
|
+
id: run.id,
|
|
145
|
+
kind,
|
|
146
|
+
role,
|
|
147
|
+
state,
|
|
148
|
+
model,
|
|
149
|
+
provider,
|
|
150
|
+
cwd,
|
|
151
|
+
startedAt,
|
|
152
|
+
durationMs,
|
|
153
|
+
taskTitle,
|
|
154
|
+
checkpoint,
|
|
155
|
+
summary,
|
|
156
|
+
turns: buildTurnTree(events),
|
|
157
|
+
});
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
const meta = [
|
|
161
|
+
["Run", run.id],
|
|
162
|
+
["Kind", kind !== null ? (role !== null ? `${kind} (role: ${role})` : kind) : "—"],
|
|
163
|
+
["State", state],
|
|
164
|
+
["Model", model ?? "—"],
|
|
165
|
+
["Provider", provider ?? "—"],
|
|
166
|
+
["Cwd", cwd ?? "—"],
|
|
167
|
+
["Started", startedAt !== null ? formatLocalTime(startedAt) : "—"],
|
|
168
|
+
["Duration", durationMs !== null ? formatDuration(durationMs) : "—"],
|
|
169
|
+
["Task", taskTitle ?? "—"],
|
|
170
|
+
];
|
|
171
|
+
if (checkpoint !== null) {
|
|
172
|
+
meta.push(["Checkpoint", checkpoint]);
|
|
173
|
+
}
|
|
174
|
+
const metaWidth = Math.max(...meta.map(([label]) => label.length)) + 2;
|
|
175
|
+
const lines = [];
|
|
176
|
+
for (const [label, value] of meta) {
|
|
177
|
+
lines.push(`${label.padEnd(metaWidth)}${value}`);
|
|
178
|
+
}
|
|
179
|
+
lines.push("", "Summary");
|
|
180
|
+
if (summary === null) {
|
|
181
|
+
lines.push(" (no summary.json and no readable events — nothing to rebuild from)");
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const numbers = [
|
|
185
|
+
["Turns", String(summary.turns)],
|
|
186
|
+
["Files changed", String(summary.filesChanged.length)],
|
|
187
|
+
["Tokens in", String(summary.tokensIn)],
|
|
188
|
+
["Tokens out", String(summary.tokensOut)],
|
|
189
|
+
["Denials", String(summary.denied)],
|
|
190
|
+
["Retries", String(summary.retries)],
|
|
191
|
+
["Validation", summary.validation],
|
|
192
|
+
];
|
|
193
|
+
const numberWidth = Math.max(...numbers.map(([label]) => label.length)) + 2;
|
|
194
|
+
for (const [label, value] of numbers) {
|
|
195
|
+
lines.push(` ${label.padEnd(numberWidth)}${value}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const tree = buildTurnTree(events);
|
|
199
|
+
lines.push("");
|
|
200
|
+
if (tree.length === 0) {
|
|
201
|
+
lines.push("(no tool activity recorded)");
|
|
202
|
+
}
|
|
203
|
+
for (const block of tree) {
|
|
204
|
+
lines.push(`◉ Turn ${block.turn}`);
|
|
205
|
+
block.entries.forEach((entry, index) => {
|
|
206
|
+
lines.push(` ${index === block.entries.length - 1 ? "└─" : "├─"} ${entry}`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
lines.push("", run.active !== null ? `● ${run.active.state} — run is still active` : terminalLine(events));
|
|
210
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* glm-router runs logs <id> — `events.jsonl`, one rendered line per event.
|
|
215
|
+
* `--json` prints the raw lines unchanged so the output pipes straight into
|
|
216
|
+
* jq. No `--follow` here: following is `runs watch`, a different rendering.
|
|
217
|
+
*/
|
|
218
|
+
export function runsLogsCommand(id, options, deps = {}) {
|
|
219
|
+
const home = deps.home ?? os.homedir();
|
|
220
|
+
const run = resolveRun(home, id);
|
|
221
|
+
const file = eventsFilePath(run.dir);
|
|
222
|
+
if (!fs.existsSync(file)) {
|
|
223
|
+
process.stderr.write(`run ${run.id} has no events.jsonl\n`);
|
|
224
|
+
return ExitCode.GenericFailure;
|
|
225
|
+
}
|
|
226
|
+
const text = fs.readFileSync(file, "utf8");
|
|
227
|
+
if (options.json) {
|
|
228
|
+
// Byte-for-byte: a trailing newline is added only when missing so a shell
|
|
229
|
+
// prompt never ends up glued to the last event — no line is altered.
|
|
230
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
const events = readEvents(run.dir);
|
|
234
|
+
const seqWidth = Math.max(3, ...events.map((event) => String(event.seq).length));
|
|
235
|
+
const typeWidth = Math.max(4, ...events.map((event) => event.type.length));
|
|
236
|
+
const lines = events.map((event) => `${String(event.seq).padEnd(seqWidth)} ${event.ts} ${event.type.padEnd(typeWidth)} ${eventDetails(event)}`.trimEnd());
|
|
237
|
+
if (lines.length > 0) {
|
|
238
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* glm-router runs clean — prunes history (age via `--older-than Nd` or the
|
|
244
|
+
* config default, plus `history.maxRuns`) and reaps orphaned active files.
|
|
245
|
+
* `--dry-run` reports the plan without touching disk (repo convention), and
|
|
246
|
+
* `--orphans` alone narrows the invocation to reaping only.
|
|
247
|
+
*/
|
|
248
|
+
export function runsCleanCommand(options, deps = {}) {
|
|
249
|
+
const home = deps.home ?? os.homedir();
|
|
250
|
+
const nowMs = () => (deps.now?.() ?? new Date()).getTime();
|
|
251
|
+
const config = loadConfig(home);
|
|
252
|
+
const match = /^(\d+)d$/.exec(options.olderThan ?? "");
|
|
253
|
+
if (options.olderThan !== undefined && match === null) {
|
|
254
|
+
throw Errors.invalidArgs(`--older-than expects "Nd" (e.g. "30d"), got "${options.olderThan}"`);
|
|
255
|
+
}
|
|
256
|
+
const retentionDays = match !== null ? Number(match[1]) : config.history.retentionDays;
|
|
257
|
+
// `runs clean` with no flags is the same opportunistic prune the registry
|
|
258
|
+
// runs at start; `--orphans` alone means "only reap".
|
|
259
|
+
const prune = !(options.orphans === true && options.olderThan === undefined);
|
|
260
|
+
const dryRun = options.dryRun === true;
|
|
261
|
+
let removed = [];
|
|
262
|
+
if (prune) {
|
|
263
|
+
const limits = { retentionDays, maxRuns: config.history.maxRuns };
|
|
264
|
+
removed = dryRun ? planPrune(home, limits).removed : pruneHistory(home, limits).removed;
|
|
265
|
+
}
|
|
266
|
+
let reaped = [];
|
|
267
|
+
if (options.orphans === true) {
|
|
268
|
+
const orphans = listActive(home).filter((run) => isOrphaned(run, { now: nowMs, isAlive: deps.isAlive }));
|
|
269
|
+
if (!dryRun) {
|
|
270
|
+
for (const orphan of orphans) {
|
|
271
|
+
reapOrphan(home, orphan);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
reaped = orphans.map((run) => run.id);
|
|
275
|
+
}
|
|
276
|
+
if (options.json) {
|
|
277
|
+
emitJson({ dryRun, retentionDays, maxRuns: config.history.maxRuns, removed, reaped });
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
const removeVerb = dryRun ? "would remove" : "removed";
|
|
281
|
+
const reapVerb = dryRun ? "would reap" : "reaped";
|
|
282
|
+
const lines = [];
|
|
283
|
+
if (removed.length > 0) {
|
|
284
|
+
lines.push(`${removeVerb}:`, ...removed.map((id) => ` ${id}`));
|
|
285
|
+
}
|
|
286
|
+
if (reaped.length > 0) {
|
|
287
|
+
lines.push(`${reapVerb}:`, ...reaped.map((id) => ` ${id}`));
|
|
288
|
+
}
|
|
289
|
+
lines.push(`${removeVerb} ${removed.length} ${removed.length === 1 ? "run" : "runs"}, ` +
|
|
290
|
+
`${reapVerb} ${reaped.length} ${reaped.length === 1 ? "orphan" : "orphans"}`);
|
|
291
|
+
if (dryRun) {
|
|
292
|
+
lines.push("(dry run — nothing was changed)");
|
|
293
|
+
}
|
|
294
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Finds a run by full id or unique suffix — the table shows short ids, so
|
|
299
|
+
* pasting one back must work. An exact match wins before suffixes are
|
|
300
|
+
* considered, so a full id can never be called ambiguous by its own tail.
|
|
301
|
+
*/
|
|
302
|
+
function resolveRun(home, input) {
|
|
303
|
+
const activeRuns = listActive(home);
|
|
304
|
+
const refs = listHistory(home);
|
|
305
|
+
const exactActive = activeRuns.find((run) => run.id === input);
|
|
306
|
+
if (exactActive !== undefined) {
|
|
307
|
+
return {
|
|
308
|
+
id: exactActive.id,
|
|
309
|
+
date: exactActive.date,
|
|
310
|
+
dir: runDir(home, exactActive.date, exactActive.id),
|
|
311
|
+
active: exactActive,
|
|
312
|
+
ref: refs.find((ref) => ref.id === input) ?? null,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const exactRef = refs.find((ref) => ref.id === input);
|
|
316
|
+
if (exactRef !== undefined) {
|
|
317
|
+
return {
|
|
318
|
+
id: exactRef.id,
|
|
319
|
+
date: exactRef.date,
|
|
320
|
+
dir: runDir(home, exactRef.date, exactRef.id),
|
|
321
|
+
active: null,
|
|
322
|
+
ref: exactRef,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const suffixActive = activeRuns.filter((run) => run.id.endsWith(input));
|
|
326
|
+
const suffixRefs = refs.filter((ref) => ref.id.endsWith(input));
|
|
327
|
+
// A LIVE run appears in both lists — its history directory exists from the
|
|
328
|
+
// first event, while its active file still exists too — so counting both
|
|
329
|
+
// made `runs show <suffix>` report every running run as ambiguous with
|
|
330
|
+
// itself. Candidates are unique run ids, not list entries.
|
|
331
|
+
const candidateIds = [...new Set([...suffixActive, ...suffixRefs].map((c) => c.id))];
|
|
332
|
+
if (candidateIds.length === 0) {
|
|
333
|
+
throw Errors.invalidArgs(`no run found with id "${input}"`, [`Run "glm-router runs" to list recorded runs.`]);
|
|
334
|
+
}
|
|
335
|
+
if (candidateIds.length > 1) {
|
|
336
|
+
throw Errors.invalidArgs(`run id "${input}" is ambiguous — ${candidateIds.length} recorded runs end with it:`, candidateIds);
|
|
337
|
+
}
|
|
338
|
+
const id = candidateIds[0];
|
|
339
|
+
// Prefer the active entry: it carries the live state the history ref lacks.
|
|
340
|
+
const run = suffixActive.find((candidate) => candidate.id === id);
|
|
341
|
+
if (run !== undefined) {
|
|
342
|
+
return {
|
|
343
|
+
id: run.id,
|
|
344
|
+
date: run.date,
|
|
345
|
+
dir: runDir(home, run.date, run.id),
|
|
346
|
+
active: run,
|
|
347
|
+
ref: suffixRefs.find((candidate) => candidate.id === id) ?? null,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
const ref = suffixRefs.find((candidate) => candidate.id === id);
|
|
351
|
+
return { id: ref.id, date: ref.date, dir: runDir(home, ref.date, ref.id), active: null, ref };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Dry-run twin of `pruneHistory`: the same two rules (age, then count
|
|
355
|
+
* overflow), computed through `listHistory` so the registry stays the only
|
|
356
|
+
* thing that walks the directories. It can only see real runs — debris
|
|
357
|
+
* directories with no summary and no events are invisible to `listHistory`,
|
|
358
|
+
* which is the honest thing to preview.
|
|
359
|
+
*/
|
|
360
|
+
function planPrune(home, limits) {
|
|
361
|
+
const refs = listHistory(home); // newest first
|
|
362
|
+
const expired = refs
|
|
363
|
+
.filter((ref) => Date.now() - Date.parse(`${ref.date}T00:00:00.000Z`) >= limits.retentionDays * DAY_MS)
|
|
364
|
+
.reverse(); // oldest first, the order pruneHistory reports in
|
|
365
|
+
const kept = refs.filter((ref) => !expired.includes(ref)).reverse(); // oldest first
|
|
366
|
+
const overflow = kept.slice(0, Math.max(0, kept.length - limits.maxRuns));
|
|
367
|
+
return { removed: [...expired, ...overflow].map((ref) => ref.id) };
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Moves an orphaned run to history: a FAILED summary rebuilt from the events
|
|
371
|
+
* it managed to write, then the active file goes. With no events there is
|
|
372
|
+
* nothing to summarize — the active file is dropped and the empty directory
|
|
373
|
+
* becomes debris that the next prune collects.
|
|
374
|
+
*/
|
|
375
|
+
function reapOrphan(home, orphan) {
|
|
376
|
+
const events = readEvents(runDir(home, orphan.date, orphan.id));
|
|
377
|
+
if (events.length > 0) {
|
|
378
|
+
finishRun(home, orphan.id, { ...summarize(events), id: orphan.id, state: "FAILED" });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
fs.rmSync(activeRunFile(home, orphan.id), { force: true });
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
// A reap that cannot complete must not fail the whole clean — the next
|
|
386
|
+
// run will reconsider the same orphan.
|
|
387
|
+
logger.debug(`runs clean: reaping ${orphan.id} failed: ${errorMessage(error)}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Rebuilds the per-turn tool tree in the same visual shape the rich progress
|
|
392
|
+
* renderer prints (`◉ Turn n` / ` ├─ Tool summary`) and stays silent about
|
|
393
|
+
* exactly the events the live renderer is silent about, so replaying history
|
|
394
|
+
* and watching live produce matching views.
|
|
395
|
+
*/
|
|
396
|
+
function buildTurnTree(events) {
|
|
397
|
+
const blocks = new Map();
|
|
398
|
+
let currentTurn = 0;
|
|
399
|
+
const block = (turn) => {
|
|
400
|
+
let entries = blocks.get(turn);
|
|
401
|
+
if (entries === undefined) {
|
|
402
|
+
entries = [];
|
|
403
|
+
blocks.set(turn, entries);
|
|
404
|
+
}
|
|
405
|
+
return entries;
|
|
406
|
+
};
|
|
407
|
+
for (const event of events) {
|
|
408
|
+
switch (event.type) {
|
|
409
|
+
case "TurnStarted":
|
|
410
|
+
currentTurn = event.turn;
|
|
411
|
+
block(currentTurn);
|
|
412
|
+
break;
|
|
413
|
+
case "ToolStarted":
|
|
414
|
+
// Defensive: a replayed stream without TurnStarted still groups by turn.
|
|
415
|
+
currentTurn = event.turn;
|
|
416
|
+
block(event.turn).push(`${event.tool.padEnd(TOOL_COLUMN_WIDTH)} ${event.summary}`);
|
|
417
|
+
break;
|
|
418
|
+
case "ToolDenied":
|
|
419
|
+
block(event.turn).push(`⚠ denied: ${event.tool} — ${event.reason}`);
|
|
420
|
+
break;
|
|
421
|
+
case "ApiRetry":
|
|
422
|
+
// ApiRetry carries no turn field; it belongs to whatever turn is
|
|
423
|
+
// current, and before the first turn there is nowhere to put it.
|
|
424
|
+
if (currentTurn > 0) {
|
|
425
|
+
block(currentTurn).push(`⚠ retry: ${event.reason}`);
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
case "ValidationCompleted":
|
|
429
|
+
block(event.turn).push(`${event.ok ? "✓" : "✗"} tests ${event.ok ? "passed" : "failed"}`);
|
|
430
|
+
break;
|
|
431
|
+
default:
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return [...blocks.entries()]
|
|
436
|
+
.sort((a, b) => a[0] - b[0])
|
|
437
|
+
.map(([turn, entries]) => ({ turn, entries }));
|
|
438
|
+
}
|
|
439
|
+
/** The closing line of `runs show`, mirroring the progress renderer's footer. */
|
|
440
|
+
function terminalLine(events) {
|
|
441
|
+
for (const event of events) {
|
|
442
|
+
if (event.type === "RunCompleted") {
|
|
443
|
+
return "✓ Completed";
|
|
444
|
+
}
|
|
445
|
+
if (event.type === "RunFailed") {
|
|
446
|
+
return `✗ Failed — ${event.reason}`;
|
|
447
|
+
}
|
|
448
|
+
if (event.type === "RunCancelled") {
|
|
449
|
+
return "✗ Cancelled";
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return "✗ Crashed — no terminal event was recorded";
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Per-row metadata from the first line of `events.jsonl` — kind, model and cwd
|
|
456
|
+
* live only there, and reading whole streams would make listing O(history).
|
|
457
|
+
*/
|
|
458
|
+
function readRunStartInfo(dir) {
|
|
459
|
+
try {
|
|
460
|
+
const firstLine = fs.readFileSync(eventsFilePath(dir), "utf8").split(/\r?\n/, 1)[0] ?? "";
|
|
461
|
+
if (firstLine.trim() === "") {
|
|
462
|
+
return { kind: null, model: null, cwd: null };
|
|
463
|
+
}
|
|
464
|
+
const parsed = JSON.parse(firstLine);
|
|
465
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
466
|
+
return { kind: null, model: null, cwd: null };
|
|
467
|
+
}
|
|
468
|
+
const record = parsed;
|
|
469
|
+
const text = (key) => (typeof record[key] === "string" ? record[key] : null);
|
|
470
|
+
return { kind: text("kind"), model: text("model"), cwd: text("cwd") };
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return { kind: null, model: null, cwd: null };
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Column widths are computed from the data, never hardcoded: status.ts's fixed
|
|
478
|
+
* padding was a reported defect, and a cwd or model name of any length must
|
|
479
|
+
* not break the table.
|
|
480
|
+
*/
|
|
481
|
+
function renderTable(headers, rows) {
|
|
482
|
+
const widths = headers.map((header, column) => Math.max(header.length, ...rows.map((row) => row[column]?.length ?? 0)));
|
|
483
|
+
const line = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
|
|
484
|
+
return [line(headers), ...rows.map(line)].join("\n");
|
|
485
|
+
}
|
|
486
|
+
/** Local time, built from the date's own components so no locale can change it. */
|
|
487
|
+
function formatLocalTime(iso) {
|
|
488
|
+
const date = new Date(iso);
|
|
489
|
+
if (Number.isNaN(date.getTime())) {
|
|
490
|
+
return "—";
|
|
491
|
+
}
|
|
492
|
+
const p2 = (value) => String(value).padStart(2, "0");
|
|
493
|
+
return (`${date.getFullYear()}-${p2(date.getMonth() + 1)}-${p2(date.getDate())} ` +
|
|
494
|
+
`${p2(date.getHours())}:${p2(date.getMinutes())}:${p2(date.getSeconds())}`);
|
|
495
|
+
}
|
|
496
|
+
/** "5.4s", "2m 13s", "1h 04m" — one decimal below a minute, where it is honest. */
|
|
497
|
+
function formatDuration(durationMs) {
|
|
498
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
499
|
+
return "—";
|
|
500
|
+
}
|
|
501
|
+
const seconds = durationMs / 1000;
|
|
502
|
+
if (seconds < 60) {
|
|
503
|
+
return `${seconds.toFixed(1)}s`;
|
|
504
|
+
}
|
|
505
|
+
const totalSeconds = Math.floor(seconds);
|
|
506
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
507
|
+
if (minutes < 60) {
|
|
508
|
+
return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`;
|
|
509
|
+
}
|
|
510
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
511
|
+
}
|
|
512
|
+
/** Milliseconds since an ISO timestamp, clamped at 0; null when unparseable. */
|
|
513
|
+
function elapsedSince(iso, now) {
|
|
514
|
+
const startedMs = Date.parse(iso);
|
|
515
|
+
return Number.isFinite(startedMs) ? Math.max(0, now().getTime() - startedMs) : null;
|
|
516
|
+
}
|
|
517
|
+
/** JSON.stringify quotes and escapes values with spaces, exactly what logs need. */
|
|
518
|
+
function quote(value) {
|
|
519
|
+
return JSON.stringify(value);
|
|
520
|
+
}
|
|
521
|
+
/** Compact `key=value` details for one event — the human half of `runs logs`. */
|
|
522
|
+
function eventDetails(event) {
|
|
523
|
+
switch (event.type) {
|
|
524
|
+
case "RunStarted":
|
|
525
|
+
return `kind=${event.kind} model=${event.model} cwd=${quote(event.cwd)} task=${quote(event.taskTitle)}`;
|
|
526
|
+
case "AgentInitialized":
|
|
527
|
+
return `session=${event.sessionId} model=${event.model} tools=${event.tools.length}`;
|
|
528
|
+
case "TurnStarted":
|
|
529
|
+
return `turn=${event.turn}`;
|
|
530
|
+
case "ToolStarted":
|
|
531
|
+
return `turn=${event.turn} tool=${event.tool} summary=${quote(event.summary)}`;
|
|
532
|
+
case "ToolCompleted":
|
|
533
|
+
return `turn=${event.turn} tool=${event.tool} ok=${String(event.ok)} ${String(event.durationMs)}ms`;
|
|
534
|
+
case "FileChanged":
|
|
535
|
+
return `turn=${event.turn} op=${event.op} path=${event.path}`;
|
|
536
|
+
case "ValidationStarted":
|
|
537
|
+
return `turn=${event.turn} command=${quote(event.command)}`;
|
|
538
|
+
case "ValidationCompleted":
|
|
539
|
+
return `turn=${event.turn} command=${quote(event.command)} ok=${String(event.ok)} ${String(event.durationMs)}ms`;
|
|
540
|
+
case "ToolDenied":
|
|
541
|
+
return `turn=${event.turn} tool=${event.tool} reason=${quote(event.reason)}`;
|
|
542
|
+
case "ApiRetry":
|
|
543
|
+
return event.attempt === undefined
|
|
544
|
+
? `reason=${quote(event.reason)}`
|
|
545
|
+
: `attempt=${event.attempt} reason=${quote(event.reason)}`;
|
|
546
|
+
case "BudgetWarning":
|
|
547
|
+
return (`zone=${event.zone} remainingRatio=${String(event.remainingRatio)} ` +
|
|
548
|
+
`usableBudget=${String(event.usableBudget)} estimatedRemaining=${String(event.estimatedRemaining)}`);
|
|
549
|
+
case "CheckpointCreated":
|
|
550
|
+
return `phase=${event.phase} path=${event.path}`;
|
|
551
|
+
case "HandoffStarted":
|
|
552
|
+
return `reason=${quote(event.reason)}`;
|
|
553
|
+
case "HandoffCompleted":
|
|
554
|
+
return `reason=${quote(event.reason)} bundle=${event.bundlePath}`;
|
|
555
|
+
case "RunCompleted":
|
|
556
|
+
return (`turns=${event.turns} duration=${String(event.durationMs)}ms files=${String(event.filesChanged)} ` +
|
|
557
|
+
`tokensIn=${String(event.tokensIn)} tokensOut=${String(event.tokensOut)}`);
|
|
558
|
+
case "RunFailed":
|
|
559
|
+
return `reason=${quote(event.reason)} exit=${String(event.exitCode)}`;
|
|
560
|
+
case "RunCancelled":
|
|
561
|
+
return `signal=${event.signal}`;
|
|
562
|
+
case "Heartbeat":
|
|
563
|
+
return `state=${event.state} turn=${String(event.turn)}`;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function errorMessage(error) {
|
|
567
|
+
return error instanceof Error ? error.message : String(error);
|
|
568
|
+
}
|
package/dist/commands/status.js
CHANGED
|
@@ -6,7 +6,9 @@ import { resolveZaiApiKey } from "../core/zai-key.js";
|
|
|
6
6
|
import { skillTargets } from "../integrations/skill.js";
|
|
7
7
|
import { GLM_DELEGATION_SKILL_NAME } from "../templates/glm-delegation-skill.js";
|
|
8
8
|
import { emitJson } from "./context.js";
|
|
9
|
-
|
|
9
|
+
import { createCommandUi } from "../tui/command-ui.js";
|
|
10
|
+
import { createWriter } from "../tui/render.js";
|
|
11
|
+
/** Fast, fully offline summary (spec §41, specs/terminal-ui-doctor.md §B.4) — no API requests, no key values. */
|
|
10
12
|
export function statusCommand(options, deps = {}) {
|
|
11
13
|
const home = deps.home ?? os.homedir();
|
|
12
14
|
const env = deps.env ?? process.env;
|
|
@@ -51,23 +53,34 @@ export function statusCommand(options, deps = {}) {
|
|
|
51
53
|
});
|
|
52
54
|
return 0;
|
|
53
55
|
}
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"",
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
const stream = deps.stdout ?? process.stdout;
|
|
57
|
+
const writer = createWriter(stream);
|
|
58
|
+
const ui = createCommandUi(writer, { quiet: options.quiet });
|
|
59
|
+
const blocks = [];
|
|
60
|
+
const header = ui.header(`GLM CODING ROUTER v${version} / STATUS`, "Offline overview — credentials not verified this run");
|
|
61
|
+
if (header)
|
|
62
|
+
blocks.push(header);
|
|
63
|
+
blocks.push([
|
|
64
|
+
ui.section("SYSTEM"),
|
|
65
|
+
// Presence only, never validity — that claim belongs to `doctor` (spec §B.4).
|
|
66
|
+
ui.row("Z.ai key", resolved ? "configured (not verified — run: glm-router doctor)" : "not configured", resolved ? "ok" : "fail"),
|
|
67
|
+
ui.row("Claude", claudeInstalled ? "installed" : "missing", claudeInstalled ? "ok" : "fail"),
|
|
68
|
+
ui.row("Codex", codexInstalled ? "installed" : "missing", codexInstalled ? "ok" : "warn"),
|
|
69
|
+
].join("\n"));
|
|
70
|
+
const integrationRows = [
|
|
71
|
+
ui.section("INTEGRATIONS"),
|
|
72
|
+
ui.row("Claude policy", config.integrations.claude ? "enabled" : "disabled", config.integrations.claude ? "ok" : "info"),
|
|
73
|
+
ui.row("Codex policy", config.integrations.codex ? "enabled" : "disabled", config.integrations.codex ? "ok" : "info"),
|
|
65
74
|
];
|
|
66
75
|
for (const row of skillState) {
|
|
67
76
|
const enabled = row.homeDetected && row.installed;
|
|
68
|
-
|
|
77
|
+
integrationRows.push(ui.row(`${row.agent} skill`, enabled ? "enabled" : "disabled", enabled ? "ok" : "info"));
|
|
69
78
|
}
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
blocks.push(integrationRows.join("\n"));
|
|
80
|
+
blocks.push([ui.section("MODELS"), ui.row("Main model", config.models.main), ui.row("Fast model", config.models.fast)].join("\n"));
|
|
81
|
+
const footer = ui.footer("Run: glm-router doctor to verify credentials and connectivity.");
|
|
82
|
+
if (footer)
|
|
83
|
+
blocks.push(footer);
|
|
84
|
+
writer.line(blocks.join("\n\n"));
|
|
72
85
|
return 0;
|
|
73
86
|
}
|