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.
@@ -46,4 +46,13 @@ After worker completion:
46
46
  4. resolve integration problems
47
47
  5. accept only after verification
48
48
 
49
+ Worker exit codes 41 and 42 are NOT crashes:
50
+
51
+ - stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
52
+ - read \`handoff_path\` — a handoff.md with what was done, what remains, files
53
+ changed, and untracked files that are NOT in diff.patch
54
+ - continue the task yourself in the SAME worktree, starting from "Remaining"
55
+ - do not re-run the worker until quota resets
56
+ - 41 means nothing was spawned; 42 means work was done and is preserved
57
+
49
58
  <!-- glm-coding-router:end -->`;
@@ -1,68 +1,79 @@
1
1
  /** Codex skill definition (spec §26). Keep in sync with the spec. */
2
2
  export const GLM_DELEGATION_SKILL_NAME = "glm-delegation";
3
- export const GLM_DELEGATION_SKILL_MD = `---
4
- name: glm-delegation
5
- description: >
6
- Delegate well-scoped implementation, testing,
7
- repository exploration, boilerplate, CRUD,
8
- documentation, and mechanical refactoring to
9
- GLM Coding Plan workers.
10
- ---
11
-
12
- # GLM Delegation
13
-
14
- Available commands:
15
-
16
- glm-worker "<task>"
17
- glm-review "<task>"
18
-
19
- ## Use glm-review for
20
-
21
- - repository exploration
22
- - dependency analysis
23
- - locating implementations
24
- - call-chain discovery
25
- - code review
26
-
27
- ## Use glm-worker for
28
-
29
- - CRUD
30
- - unit tests
31
- - implementation
32
- - documentation
33
- - repetitive changes
34
- - mechanical refactoring
35
-
36
- ## Keep in primary Codex agent
37
-
38
- - requirements
39
- - architecture
40
- - ambiguous rules
41
- - security-sensitive design
42
- - difficult debugging
43
- - integration
44
- - final acceptance
45
-
46
- ## Delegation packet
47
-
48
- Always provide:
49
-
50
- TASK
51
- SCOPE
52
- ALLOWED FILES
53
- FORBIDDEN FILES
54
- REQUIREMENTS
55
- CONSTRAINTS
56
- ACCEPTANCE CRITERIA
57
- VALIDATION
58
- EXPECTED OUTPUT
59
-
60
- ## Verification
61
-
62
- After GLM finishes:
63
-
64
- - inspect the actual diff
65
- - independently run relevant validation
66
- - compare implementation with requirements
67
- - reject or correct worker output when needed
3
+ export const GLM_DELEGATION_SKILL_MD = `---
4
+ name: glm-delegation
5
+ description: >
6
+ Delegate well-scoped implementation, testing,
7
+ repository exploration, boilerplate, CRUD,
8
+ documentation, and mechanical refactoring to
9
+ GLM Coding Plan workers.
10
+ ---
11
+
12
+ # GLM Delegation
13
+
14
+ Available commands:
15
+
16
+ glm-worker "<task>"
17
+ glm-review "<task>"
18
+
19
+ ## Use glm-review for
20
+
21
+ - repository exploration
22
+ - dependency analysis
23
+ - locating implementations
24
+ - call-chain discovery
25
+ - code review
26
+
27
+ ## Use glm-worker for
28
+
29
+ - CRUD
30
+ - unit tests
31
+ - implementation
32
+ - documentation
33
+ - repetitive changes
34
+ - mechanical refactoring
35
+
36
+ ## Keep in primary Codex agent
37
+
38
+ - requirements
39
+ - architecture
40
+ - ambiguous rules
41
+ - security-sensitive design
42
+ - difficult debugging
43
+ - integration
44
+ - final acceptance
45
+
46
+ ## Delegation packet
47
+
48
+ Always provide:
49
+
50
+ TASK
51
+ SCOPE
52
+ ALLOWED FILES
53
+ FORBIDDEN FILES
54
+ REQUIREMENTS
55
+ CONSTRAINTS
56
+ ACCEPTANCE CRITERIA
57
+ VALIDATION
58
+ EXPECTED OUTPUT
59
+
60
+ ## Verification
61
+
62
+ After GLM finishes:
63
+
64
+ - inspect the actual diff
65
+ - independently run relevant validation
66
+ - compare implementation with requirements
67
+ - reject or correct worker output when needed
68
+
69
+ ## Exit codes 41 and 42 are not crashes
70
+
71
+ A worker may stop because the GLM quota ran low rather than because it broke.
72
+
73
+ - stdout carries one JSON object: \`{"status":"handoff_required", ...}\`
74
+ - read \`handoff_path\` — a handoff.md with what was done, what remains, files
75
+ changed, and untracked files that are NOT in diff.patch
76
+ - continue the task yourself in the SAME worktree, starting from "Remaining"
77
+ - do not re-run the worker until quota resets
78
+ - 41 means nothing was spawned; 42 means work was done and is preserved
68
79
  `;
@@ -0,0 +1,338 @@
1
+ import path from "node:path";
2
+ import { logger } from "../core/logging.js";
3
+ import { ansi, createWriter, paint, truncate } from "./render.js";
4
+ /**
5
+ * Decide the mode from flags / env / config / TTY-ness. The order is
6
+ * contractual: an orchestrator's `--quiet` or a CI environment must be able to
7
+ * force silence even when config asks for rich, while an explicit env override
8
+ * still beats config so `GLM_ROUTER_PROGRESS=nested glm-worker …` works on a
9
+ * machine whose config says otherwise. `auto` (the config default) picks rich
10
+ * for humans at a terminal and nested for everything piped.
11
+ */
12
+ export function resolveProgressMode(input) {
13
+ const env = input.env ?? {};
14
+ if (input.flagOff || input.quiet || env.CI === "true" || env.GLM_ROUTER_PROGRESS === "off") {
15
+ return "off";
16
+ }
17
+ const override = env.GLM_ROUTER_PROGRESS;
18
+ if (override === "rich" || override === "nested") {
19
+ return override;
20
+ }
21
+ if (env.GLM_ROUTER_NESTED === "1") {
22
+ return "nested";
23
+ }
24
+ const configured = input.configMode;
25
+ if (configured === "rich" || configured === "nested" || configured === "off") {
26
+ return configured;
27
+ }
28
+ return input.isTTY ? "rich" : "nested";
29
+ }
30
+ /**
31
+ * Subscribe a progress renderer to the bus. The stream is a parameter, never
32
+ * `process.stdout`, because that channel belongs to the worker's final answer
33
+ * (contracts C1/C2) — production callers pass `process.stderr`, tests pass a
34
+ * memory stream. `detach()` unsubscribes and restores the cursor.
35
+ */
36
+ export function attachProgress(bus, opts) {
37
+ if (opts.mode === "off") {
38
+ // Rendering nothing still needs a uniform handle so callers can detach blindly.
39
+ return { detach() { } };
40
+ }
41
+ // Nested lines are parsed by orchestrators, so that mode defaults to plain
42
+ // text unless the caller explicitly opts into color; rich keeps the writer's
43
+ // own TTY/NO_COLOR default.
44
+ const color = opts.color ?? (opts.mode === "nested" ? false : undefined);
45
+ const writer = createWriter(opts.stream, { color });
46
+ const renderer = opts.mode === "nested"
47
+ ? new NestedRenderer(writer, bus.runId)
48
+ : new RichRenderer(writer, opts.project);
49
+ const unsubscribe = bus.subscribe((event) => {
50
+ // The bus already catches subscriber exceptions; the renderer guards itself
51
+ // too so a half-broken state machine degrades instead of compounding.
52
+ try {
53
+ renderer.handle(event);
54
+ }
55
+ catch (error) {
56
+ logger.debug(`progress renderer threw on ${event.type}: ${errorMessage(error)}`);
57
+ }
58
+ });
59
+ return {
60
+ detach() {
61
+ unsubscribe();
62
+ renderer.close();
63
+ },
64
+ };
65
+ }
66
+ /** Last 4 characters of the run id, uppercased — short enough to say aloud. */
67
+ function shortRunId(runId) {
68
+ return runId.slice(-4).toUpperCase();
69
+ }
70
+ /** Duration in seconds with one decimal, the resolution that is readable and honest. */
71
+ function formatSeconds(durationMs) {
72
+ return `${(durationMs / 1000).toFixed(1)}s`;
73
+ }
74
+ /**
75
+ * The one-word verb for a nested turn line. Validators are recognized via the
76
+ * `ValidationStarted` that the adapter emits immediately before the matching
77
+ * `ToolStarted`, so no tool-input inspection is needed here (C3).
78
+ */
79
+ function nestedVerb(tool, isValidation) {
80
+ if (tool === "Read" || tool === "Grep" || tool === "Glob") {
81
+ return "exploring";
82
+ }
83
+ if (tool === "Edit" || tool === "Write" || tool === "MultiEdit") {
84
+ return "editing";
85
+ }
86
+ if (tool === "Bash") {
87
+ return isValidation ? "running tests" : "running";
88
+ }
89
+ return `using ${tool}`;
90
+ }
91
+ /**
92
+ * Nested mode: one stable `[GLM] …` line per significant event and no cursor
93
+ * control, so an orchestrator can `readline` stderr without speaking ANSI.
94
+ * These lines are a machine interface — change them only for cause.
95
+ */
96
+ class NestedRenderer {
97
+ writer;
98
+ short;
99
+ /** Turn whose first tool has already been announced; 0 = none. */
100
+ announcedTurn = 0;
101
+ /** Turn of a `ValidationStarted` awaiting its `ToolStarted`. */
102
+ validationTurn = null;
103
+ finished = false;
104
+ constructor(writer, runId) {
105
+ this.writer = writer;
106
+ this.short = shortRunId(runId);
107
+ }
108
+ handle(event) {
109
+ if (this.finished) {
110
+ return;
111
+ }
112
+ switch (event.type) {
113
+ case "RunStarted":
114
+ this.writer.line(`[GLM] ${paint(this.writer, "cyan", "●")} #${this.short} started • ${event.model}`);
115
+ break;
116
+ case "ValidationStarted":
117
+ // Consumed by the ToolStarted that follows it in the same turn.
118
+ this.validationTurn = event.turn;
119
+ break;
120
+ case "ToolStarted": {
121
+ if (event.turn === this.announcedTurn) {
122
+ break; // later tools of a turn print nothing
123
+ }
124
+ this.announcedTurn = event.turn;
125
+ const isValidation = this.validationTurn === event.turn;
126
+ if (isValidation) {
127
+ this.validationTurn = null;
128
+ }
129
+ this.writer.line(`[GLM] turn ${event.turn} • ${nestedVerb(event.tool, isValidation)} ${event.summary}`);
130
+ break;
131
+ }
132
+ case "ValidationCompleted":
133
+ this.writer.line(`[GLM] ${paint(this.writer, event.ok ? "green" : "red", event.ok ? "✓" : "✗")} tests ` +
134
+ `${event.ok ? "passed" : "failed"}`);
135
+ break;
136
+ case "ToolDenied":
137
+ this.writer.line(`[GLM] ${paint(this.writer, "yellow", "⚠")} denied: ${event.tool} — ${event.reason}`);
138
+ break;
139
+ case "ApiRetry":
140
+ this.writer.line(`[GLM] ${paint(this.writer, "yellow", "⚠")} retry: ${event.reason}`);
141
+ break;
142
+ case "RunCompleted":
143
+ this.writer.line(`[GLM] ${paint(this.writer, "green", "✓")} #${this.short} • ` +
144
+ `${formatSeconds(event.durationMs)} • ${event.turns} turns • ${event.filesChanged} files`);
145
+ this.finished = true;
146
+ break;
147
+ case "RunFailed":
148
+ this.writer.line(`[GLM] ${paint(this.writer, "red", "✗")} #${this.short} • ${event.reason}`);
149
+ this.finished = true;
150
+ break;
151
+ case "RunCancelled":
152
+ this.writer.line(`[GLM] ${paint(this.writer, "red", "✗")} #${this.short} • cancelled`);
153
+ this.finished = true;
154
+ break;
155
+ default:
156
+ break; // TurnStarted, Heartbeat, AgentInitialized, … — deliberately silent
157
+ }
158
+ }
159
+ close() {
160
+ // No cursor was ever hidden in nested mode.
161
+ }
162
+ }
163
+ /** Width of the rich header's label column ("Project" + 2 spaces, doc §7). */
164
+ const LABEL_WIDTH = 9;
165
+ /** Width of the rich tool column ("Write" + separator, doc §7). */
166
+ const TOOL_WIDTH = 5;
167
+ /** Width of the footer label column ("Duration" + 3 spaces, doc §7). */
168
+ const FOOTER_LABEL_WIDTH = 11;
169
+ const TITLE_BY_KIND = {
170
+ worker: "GLM Worker",
171
+ review: "GLM Review",
172
+ delegate: "GLM Delegate",
173
+ };
174
+ /**
175
+ * Rich mode: the doc §7 box header plus a per-turn tool tree. On a TTY the
176
+ * current turn block is redrawn in place (cursor-up + erase per line); on a
177
+ * non-TTY stream it appends, one entry behind, so the `└─` connector of the
178
+ * last-known entry is never written wrong — piped output must stay readable
179
+ * with zero escape codes, not a pile of them.
180
+ */
181
+ class RichRenderer {
182
+ writer;
183
+ projectName;
184
+ short = "";
185
+ currentTurn = 0;
186
+ /** Plain content lines of the current turn block, connectors excluded. */
187
+ entries = [];
188
+ /** TTY: lines of the current block currently on screen (the redraw budget). */
189
+ drawn = 0;
190
+ /** non-TTY: entries of the current block already written for good. */
191
+ flushed = 0;
192
+ finished = false;
193
+ cursorHidden = false;
194
+ constructor(writer, project) {
195
+ this.writer = writer;
196
+ this.projectName = project;
197
+ }
198
+ handle(event) {
199
+ if (this.finished) {
200
+ return;
201
+ }
202
+ switch (event.type) {
203
+ case "RunStarted":
204
+ this.writeHeader(event);
205
+ break;
206
+ case "TurnStarted":
207
+ this.beginTurn(event.turn);
208
+ break;
209
+ case "ToolStarted":
210
+ // Defensive: a replayed stream without TurnStarted still groups by turn.
211
+ if (event.turn !== this.currentTurn) {
212
+ this.beginTurn(event.turn);
213
+ }
214
+ this.addEntry(`${event.tool.padEnd(TOOL_WIDTH)} ${this.summaryFit(event.summary)}`);
215
+ break;
216
+ case "ToolDenied":
217
+ this.addEntry(`${paint(this.writer, "yellow", "⚠")} denied: ${event.tool} — ${this.summaryFit(event.reason)}`);
218
+ break;
219
+ case "ApiRetry":
220
+ this.addEntry(`${paint(this.writer, "yellow", "⚠")} retry: ${this.summaryFit(event.reason)}`);
221
+ break;
222
+ case "ValidationCompleted":
223
+ this.addEntry(`${paint(this.writer, event.ok ? "green" : "red", event.ok ? "✓" : "✗")} tests ` +
224
+ `${event.ok ? "passed" : "failed"}`);
225
+ break;
226
+ case "RunCompleted":
227
+ this.writeSuccessFooter(event);
228
+ break;
229
+ case "RunFailed":
230
+ this.writeFailureClose(`✗ Failed — ${event.reason}`);
231
+ break;
232
+ case "RunCancelled":
233
+ this.writeFailureClose("✗ Cancelled");
234
+ break;
235
+ default:
236
+ break; // Heartbeat feeds the registry, not this tree
237
+ }
238
+ }
239
+ close() {
240
+ this.restoreCursor();
241
+ }
242
+ beginTurn(turn) {
243
+ this.finalizeBlock();
244
+ this.currentTurn = turn;
245
+ this.entries.length = 0;
246
+ this.drawn = 0;
247
+ this.flushed = 0;
248
+ this.writer.line(`${paint(this.writer, "cyan", "◉")} Turn ${turn}`);
249
+ }
250
+ addEntry(content) {
251
+ this.entries.push(content);
252
+ this.refresh();
253
+ }
254
+ /** Bring the on-screen representation of the current block up to date. */
255
+ refresh() {
256
+ if (this.writer.isTTY) {
257
+ for (let i = 0; i < this.drawn; i++) {
258
+ this.writer.write(ansi.cursorUp(1) + ansi.clearLine);
259
+ }
260
+ this.drawn = 0;
261
+ for (const line of this.blockLines()) {
262
+ this.writer.write(`${line}\n`);
263
+ this.drawn++;
264
+ }
265
+ return;
266
+ }
267
+ // Append-only: hold the newest entry back until it is provably not the
268
+ // last one, because written bytes cannot be taken back.
269
+ while (this.entries.length - this.flushed >= 2) {
270
+ this.writer.line(` ├─ ${this.entries[this.flushed]}`);
271
+ this.flushed++;
272
+ }
273
+ }
274
+ /** Freeze the current block: previous turns are history and never redrawn. */
275
+ finalizeBlock() {
276
+ if (!this.writer.isTTY && this.entries.length > this.flushed) {
277
+ this.writer.line(` └─ ${this.entries[this.entries.length - 1]}`);
278
+ this.flushed = this.entries.length;
279
+ }
280
+ }
281
+ blockLines() {
282
+ return this.entries.map((content, index) => index === this.entries.length - 1 ? ` └─ ${content}` : ` ├─ ${content}`);
283
+ }
284
+ writeHeader(event) {
285
+ this.short = shortRunId(event.runId);
286
+ if (this.writer.isTTY) {
287
+ this.writer.write(ansi.hideCursor);
288
+ this.cursorHidden = true;
289
+ }
290
+ const project = this.projectName ?? (path.basename(event.cwd) || event.cwd);
291
+ const rows = [
292
+ `${"Run".padEnd(LABEL_WIDTH)}#${this.short}`,
293
+ `${"Model".padEnd(LABEL_WIDTH)}${event.model}`,
294
+ `${"Project".padEnd(LABEL_WIDTH)}${project}`,
295
+ ];
296
+ const prefix = `─ ${TITLE_BY_KIND[event.kind]} `;
297
+ const inner = Math.max(...rows.map((row) => row.length), prefix.length);
298
+ this.writer.line(`╭${prefix}${"─".repeat(inner + 2 - prefix.length)}╮`);
299
+ for (const row of rows) {
300
+ this.writer.line(`│ ${row.padEnd(inner)} │`);
301
+ }
302
+ this.writer.line(`╰${"─".repeat(inner + 2)}╯`);
303
+ this.writer.line();
304
+ }
305
+ writeSuccessFooter(event) {
306
+ this.finalizeBlock();
307
+ this.writer.line();
308
+ this.writer.line(`${paint(this.writer, "green", "✓")} Completed`);
309
+ this.writer.line();
310
+ this.writer.line(`${"Duration".padEnd(FOOTER_LABEL_WIDTH)}${formatSeconds(event.durationMs)}`);
311
+ this.writer.line(`${"Turns".padEnd(FOOTER_LABEL_WIDTH)}${event.turns}`);
312
+ this.writer.line(`${"Files".padEnd(FOOTER_LABEL_WIDTH)}${event.filesChanged}`);
313
+ this.finish();
314
+ }
315
+ writeFailureClose(text) {
316
+ this.finalizeBlock();
317
+ this.writer.line();
318
+ this.writer.line(paint(this.writer, "red", text));
319
+ this.finish();
320
+ }
321
+ finish() {
322
+ this.finished = true;
323
+ this.restoreCursor();
324
+ }
325
+ restoreCursor() {
326
+ if (this.cursorHidden) {
327
+ this.writer.write(ansi.showCursor);
328
+ this.cursorHidden = false;
329
+ }
330
+ }
331
+ /** Cap a summary to the terminal width so one long path cannot wrap the tree. */
332
+ summaryFit(text) {
333
+ return truncate(text, Math.max(20, this.writer.columns - (TOOL_WIDTH + 7)));
334
+ }
335
+ }
336
+ function errorMessage(error) {
337
+ return error instanceof Error ? error.message : String(error);
338
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Writer abstraction over an injectable output stream (specs/v2-architecture.md,
3
+ * Phase C, decision D1: dependency-free TUI).
4
+ *
5
+ * This is the ONLY file in the codebase that contains ANSI escape sequences, so
6
+ * swapping the rendering backend later (e.g. Ink) is a contained change. It also
7
+ * keeps contract C1 testable: a caller injects the stream it wants to assert on,
8
+ * and this module never touches `process.stdout` or `process.stderr` itself.
9
+ */
10
+ /**
11
+ * The escape sequences the TUI needs. Kept as data, not methods on Writer, so
12
+ * a renderer can compose them (cursorUp + clearLine per redrawn line) in one
13
+ * write call where the platform batches better.
14
+ */
15
+ export const ansi = {
16
+ /** Move the cursor up `n` lines; empty string for n <= 0 (a no-op move). */
17
+ cursorUp(n) {
18
+ return n > 0 ? `\u001b[${n}A` : "";
19
+ },
20
+ /** Erase the whole current line (cursor column is untouched). */
21
+ clearLine: "\u001b[2K",
22
+ hideCursor: "\u001b[?25l",
23
+ showCursor: "\u001b[?25h",
24
+ };
25
+ const STYLE_CODES = {
26
+ dim: "\u001b[2m",
27
+ bold: "\u001b[1m",
28
+ green: "\u001b[32m",
29
+ red: "\u001b[31m",
30
+ yellow: "\u001b[33m",
31
+ cyan: "\u001b[36m",
32
+ };
33
+ const RESET = "\u001b[0m";
34
+ /**
35
+ * Wrap `text` in the escape pair for `style`, or return it unchanged when the
36
+ * writer has color off — piped output must stay byte-clean for machines.
37
+ */
38
+ export function paint(writer, style, text) {
39
+ return writer.color ? `${STYLE_CODES[style]}${text}${RESET}` : text;
40
+ }
41
+ /**
42
+ * Build a Writer over any writable stream. `isTTY`/`columns` are read off the
43
+ * stream when present (only real terminals have them); `color` defaults to
44
+ * `isTTY && !NO_COLOR` because honoring NO_COLOR is part of the platform
45
+ * contract, and an explicit `opts.color` overrides both.
46
+ */
47
+ export function createWriter(stream, opts) {
48
+ // NodeJS.WritableStream does not declare terminal-only members, so narrow
49
+ // through a structural shape instead of trusting the declared type.
50
+ const terminal = stream;
51
+ const isTTY = terminal.isTTY === true;
52
+ const noColor = (process.env.NO_COLOR ?? "").length > 0;
53
+ const color = opts?.color ?? (isTTY && !noColor);
54
+ return {
55
+ write(text) {
56
+ stream.write(text);
57
+ },
58
+ line(text) {
59
+ stream.write((text ?? "") + "\n");
60
+ },
61
+ isTTY,
62
+ color,
63
+ columns: terminal.columns ?? 80,
64
+ };
65
+ }
66
+ /**
67
+ * Cut `text` to `max` characters, marking the cut with a single ellipsis
68
+ * character so a truncated path stays visually distinguishable from a real one.
69
+ */
70
+ export function truncate(text, max) {
71
+ if (max <= 0) {
72
+ return "";
73
+ }
74
+ if (text.length <= max) {
75
+ return text;
76
+ }
77
+ return text.slice(0, max - 1) + "…";
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glm-coding-router",
3
- "version": "1.1.2",
3
+ "version": "2.0.0",
4
4
  "description": "GLM Coding Plan workers for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "license": "MIT",