glm-coding-router 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +534 -419
- package/dist/bin/glm-review.js +46 -4
- package/dist/bin/glm-worker.js +37 -4
- package/dist/budget/estimator.js +218 -0
- package/dist/budget/manager.js +223 -0
- package/dist/cli.js +38 -0
- package/dist/commands/benchmark.js +4 -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/agent-args.js +20 -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
|
@@ -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
|
+
}
|