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.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +542 -426
  3. package/dist/bin/glm-review.js +28 -3
  4. package/dist/bin/glm-worker.js +30 -4
  5. package/dist/budget/estimator.js +218 -0
  6. package/dist/budget/manager.js +223 -0
  7. package/dist/cli.js +46 -3
  8. package/dist/commands/dashboard.js +348 -0
  9. package/dist/commands/doctor-auth.js +107 -0
  10. package/dist/commands/doctor-command.js +171 -41
  11. package/dist/commands/landing.js +47 -0
  12. package/dist/commands/runs.js +568 -0
  13. package/dist/commands/status.js +28 -15
  14. package/dist/commands/usage.js +34 -58
  15. package/dist/commands/watch.js +289 -0
  16. package/dist/core/config.js +61 -0
  17. package/dist/core/errors.js +24 -0
  18. package/dist/core/key-inspector.js +45 -0
  19. package/dist/core/paths.js +32 -0
  20. package/dist/core/process.js +83 -0
  21. package/dist/core/prompt.js +18 -5
  22. package/dist/core/routing-flags.js +59 -0
  23. package/dist/core/user-env.js +17 -7
  24. package/dist/core/zai-quota.js +148 -0
  25. package/dist/events/bus.js +64 -0
  26. package/dist/events/claude-adapter.js +416 -0
  27. package/dist/events/types.js +9 -0
  28. package/dist/handoff/bundle.js +203 -0
  29. package/dist/handoff/parent-handoff.js +48 -0
  30. package/dist/mcp/server.js +45 -1
  31. package/dist/routing/glm-routing.js +131 -0
  32. package/dist/runs/checkpoint.js +204 -0
  33. package/dist/runs/drain.js +165 -0
  34. package/dist/runs/heartbeat.js +45 -0
  35. package/dist/runs/registry.js +350 -0
  36. package/dist/runs/store.js +186 -0
  37. package/dist/runs/ulid.js +112 -0
  38. package/dist/runs/worker-run.js +672 -0
  39. package/dist/templates/agents-block.js +53 -44
  40. package/dist/templates/claude-block.js +56 -47
  41. package/dist/templates/glm-delegation-skill.js +76 -65
  42. package/dist/tui/command-ui.js +158 -0
  43. package/dist/tui/progress.js +338 -0
  44. package/dist/tui/render.js +144 -0
  45. 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
+ }
@@ -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
- /** Fast, fully offline summary (spec §41) — no API requests, no key values. */
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
- /** Every status row pads its label to this column (spec §41). */
55
- const LABEL_WIDTH = 16;
56
- const lines = [
57
- `GLM Coding Router v${version}`,
58
- "",
59
- `Z.ai key ${resolved ? "configured" : "not configured"}`,
60
- `Claude ${claudeInstalled ? "installed" : "missing"}`,
61
- `Codex ${codexInstalled ? "installed" : "missing"}`,
62
- "",
63
- `Claude policy ${config.integrations.claude ? "enabled" : "disabled"}`,
64
- `Codex policy ${config.integrations.codex ? "enabled" : "disabled"}`,
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
- lines.push(`${`${row.agent} skill`.padEnd(LABEL_WIDTH)}${enabled ? "enabled" : "disabled"}`);
77
+ integrationRows.push(ui.row(`${row.agent} skill`, enabled ? "enabled" : "disabled", enabled ? "ok" : "info"));
69
78
  }
70
- lines.push("", `Main model ${config.models.main}`, `Fast model ${config.models.fast}`);
71
- process.stdout.write(lines.join("\n") + "\n");
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
  }