@ze-norm/cli 0.11.2 → 0.11.5
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/dist/api/client.d.ts +8 -2
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +46 -11
- package/dist/api/types.d.ts +8 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/auth/device-flow.d.ts +14 -0
- package/dist/auth/device-flow.d.ts.map +1 -1
- package/dist/auth/device-flow.js +31 -0
- package/dist/auth/store.d.ts +31 -1
- package/dist/auth/store.d.ts.map +1 -1
- package/dist/auth/store.js +88 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +8 -3
- package/dist/commands/work-render.d.ts +124 -0
- package/dist/commands/work-render.d.ts.map +1 -0
- package/dist/commands/work-render.js +590 -0
- package/dist/commands/work.d.ts +121 -0
- package/dist/commands/work.d.ts.map +1 -0
- package/dist/commands/work.js +523 -0
- package/dist/index.js +3 -0
- package/package.json +4 -2
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-output renderers for `zenorm work`.
|
|
3
|
+
*
|
|
4
|
+
* Background: `zenorm work` runs each claimed task by spawning a headless coding
|
|
5
|
+
* agent (`claude` / `codex`). Those agents' *interactive* TUIs show a clean,
|
|
6
|
+
* readable transcript (assistant prose + one-line tool calls). Their headless
|
|
7
|
+
* modes do NOT: `claude -p` text mode hides every tool call, and the JSON event
|
|
8
|
+
* streams (`--output-format stream-json`, `codex exec --json`) are raw JSONL —
|
|
9
|
+
* unreadable noise.
|
|
10
|
+
*
|
|
11
|
+
* These renderers consume the JSON event streams and re-emit a compact transcript
|
|
12
|
+
* that mirrors what you'd see in each agent's interactive session — a read-only
|
|
13
|
+
* view of the agent working — while dropping protocol/metadata noise (hooks,
|
|
14
|
+
* init banners, token counts, rate-limit pings, raw tool-result bodies).
|
|
15
|
+
*
|
|
16
|
+
* Pure functions over single events so they can be unit-tested without spawning
|
|
17
|
+
* anything. The caller (`AgentRunner`) feeds newline-delimited stdout chunks to a
|
|
18
|
+
* `LineSplitter` + `renderClaudeEvent` / `renderCodexEvent`.
|
|
19
|
+
*/
|
|
20
|
+
const noColor = "NO_COLOR" in process.env;
|
|
21
|
+
const RESET = noColor ? "" : "\x1b[0m";
|
|
22
|
+
const DIM = noColor ? "" : "\x1b[2m";
|
|
23
|
+
const CYAN = noColor ? "" : "\x1b[36m";
|
|
24
|
+
const BRIGHT_CYAN = noColor ? "" : "\x1b[96m";
|
|
25
|
+
const BLUE = noColor ? "" : "\x1b[34m";
|
|
26
|
+
const GREEN = noColor ? "" : "\x1b[32m";
|
|
27
|
+
const YELLOW = noColor ? "" : "\x1b[33m";
|
|
28
|
+
const RED = noColor ? "" : "\x1b[31m";
|
|
29
|
+
const MAGENTA = noColor ? "" : "\x1b[35m";
|
|
30
|
+
const BOLD = noColor ? "" : "\x1b[1m";
|
|
31
|
+
// A muted grey for the metadata box border (Qwen-style framing). 256-color so
|
|
32
|
+
// it degrades on basic terminals; collapses to "" under NO_COLOR.
|
|
33
|
+
const BORDER = noColor ? "" : "\x1b[38;5;240m";
|
|
34
|
+
// Teal line color for the logo's extruded "3D" outline (the silhouette traced
|
|
35
|
+
// one cell down-right behind the solid bright-cyan glyph faces, Qwen-style).
|
|
36
|
+
const OUTLINE = noColor ? "" : "\x1b[38;5;30m";
|
|
37
|
+
const TOOL_STYLES = {
|
|
38
|
+
// ◆ write/mutation — green, the calls that change the repo.
|
|
39
|
+
edit: { glyph: "◆", color: GREEN },
|
|
40
|
+
// ▸ shell — yellow, commands with side effects worth noticing.
|
|
41
|
+
run: { glyph: "▸", color: YELLOW },
|
|
42
|
+
// ○ read — blue, low-stakes inspection.
|
|
43
|
+
read: { glyph: "○", color: BLUE },
|
|
44
|
+
// ◇ search — cyan, glob/grep discovery.
|
|
45
|
+
search: { glyph: "◇", color: CYAN },
|
|
46
|
+
// ◈ planning — magenta, TodoWrite/task bookkeeping.
|
|
47
|
+
plan: { glyph: "◈", color: MAGENTA },
|
|
48
|
+
// ● everything else — plain cyan bullet (the old default).
|
|
49
|
+
other: { glyph: "●", color: CYAN },
|
|
50
|
+
};
|
|
51
|
+
/** Classify a tool by name into a display bucket (see `ToolKind`). */
|
|
52
|
+
function toolKind(name) {
|
|
53
|
+
switch (name) {
|
|
54
|
+
case "Read":
|
|
55
|
+
case "NotebookRead":
|
|
56
|
+
return "read";
|
|
57
|
+
case "Edit":
|
|
58
|
+
case "Write":
|
|
59
|
+
case "NotebookEdit":
|
|
60
|
+
case "MultiEdit":
|
|
61
|
+
return "edit";
|
|
62
|
+
case "Bash":
|
|
63
|
+
case "BashOutput":
|
|
64
|
+
return "run";
|
|
65
|
+
case "Glob":
|
|
66
|
+
case "Grep":
|
|
67
|
+
return "search";
|
|
68
|
+
case "TodoWrite":
|
|
69
|
+
return "plan";
|
|
70
|
+
default:
|
|
71
|
+
return "other";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Format a classified tool summary as a colored, glyph-prefixed bullet line. */
|
|
75
|
+
function toolLine(name, summary) {
|
|
76
|
+
const { glyph, color } = TOOL_STYLES[toolKind(name)];
|
|
77
|
+
return `${color}${glyph}${RESET} ${summary}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Accumulates raw stdout bytes and yields complete lines. The agent JSON streams
|
|
81
|
+
* are newline-delimited JSON; a chunk may split a line, so we buffer the tail.
|
|
82
|
+
*/
|
|
83
|
+
export class LineSplitter {
|
|
84
|
+
buffer = "";
|
|
85
|
+
/** Push a chunk; returns any newly-completed lines (without the newline). */
|
|
86
|
+
push(chunk) {
|
|
87
|
+
this.buffer += chunk;
|
|
88
|
+
const parts = this.buffer.split("\n");
|
|
89
|
+
// The last element is an incomplete line (or "") — keep it buffered.
|
|
90
|
+
this.buffer = parts.pop() ?? "";
|
|
91
|
+
return parts;
|
|
92
|
+
}
|
|
93
|
+
/** Flush any buffered partial line at end-of-stream. */
|
|
94
|
+
flush() {
|
|
95
|
+
if (this.buffer.length === 0)
|
|
96
|
+
return [];
|
|
97
|
+
const last = this.buffer;
|
|
98
|
+
this.buffer = "";
|
|
99
|
+
return [last];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Truncate a single-line summary so tool calls stay one line each. */
|
|
103
|
+
function clip(value, max = 80) {
|
|
104
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
105
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Visible (printed) width of a string: its length minus any ANSI SGR escapes,
|
|
109
|
+
* which occupy zero columns. Used to lay out the splash panel + metadata box,
|
|
110
|
+
* where padding has to align on what's *shown*, not the raw string length. The
|
|
111
|
+
* logo/box use only width-1 glyphs (`█`, box-drawing), so a codepoint count is
|
|
112
|
+
* exact here — this is not a general East-Asian-width measure.
|
|
113
|
+
*/
|
|
114
|
+
// eslint-disable-next-line no-control-regex
|
|
115
|
+
const ANSI_SGR = /\x1b\[[0-9;]*m/g;
|
|
116
|
+
function visibleWidth(text) {
|
|
117
|
+
return [...text.replace(ANSI_SGR, "")].length;
|
|
118
|
+
}
|
|
119
|
+
/** Indent a multi-line assistant message block under a bullet. */
|
|
120
|
+
function indentBlock(text) {
|
|
121
|
+
return text
|
|
122
|
+
.trimEnd()
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((line) => ` ${line}`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Build a one-line summary of a tool call from its name + input, matching the
|
|
128
|
+
* gist of what the interactive TUIs print (e.g. `Bash(ls -la)`,
|
|
129
|
+
* `Edit(src/app.ts)`). Unknown tools fall back to the tool name + clipped JSON.
|
|
130
|
+
*/
|
|
131
|
+
function summarizeTool(name, input) {
|
|
132
|
+
const obj = (input && typeof input === "object" ? input : {});
|
|
133
|
+
const str = (k) => typeof obj[k] === "string" ? obj[k] : undefined;
|
|
134
|
+
switch (name) {
|
|
135
|
+
case "Bash": {
|
|
136
|
+
const cmd = str("command");
|
|
137
|
+
return cmd ? `Bash(${clip(cmd)})` : "Bash";
|
|
138
|
+
}
|
|
139
|
+
case "Read":
|
|
140
|
+
case "NotebookEdit": {
|
|
141
|
+
const path = str("file_path") ?? str("path") ?? str("notebook_path");
|
|
142
|
+
return path ? `${name}(${clip(path)})` : name;
|
|
143
|
+
}
|
|
144
|
+
case "Edit":
|
|
145
|
+
case "Write": {
|
|
146
|
+
const path = str("file_path") ?? str("path");
|
|
147
|
+
if (!path)
|
|
148
|
+
return name;
|
|
149
|
+
// Surface a coarse size hint for writes/edits so the transcript conveys
|
|
150
|
+
// "small tweak" vs "wrote a big file" without dumping the diff. Edit uses
|
|
151
|
+
// new_string length; Write uses content length.
|
|
152
|
+
const body = str("new_string") ?? str("content");
|
|
153
|
+
const lines = body ? body.split("\n").length : undefined;
|
|
154
|
+
const suffix = lines !== undefined ? ` ${DIM}+${lines}L${RESET}` : "";
|
|
155
|
+
return `${name}(${clip(path)})${suffix}`;
|
|
156
|
+
}
|
|
157
|
+
case "Glob":
|
|
158
|
+
case "Grep": {
|
|
159
|
+
const pattern = str("pattern") ?? str("query");
|
|
160
|
+
return pattern ? `${name}(${clip(pattern)})` : name;
|
|
161
|
+
}
|
|
162
|
+
case "TodoWrite":
|
|
163
|
+
return "TodoWrite";
|
|
164
|
+
default: {
|
|
165
|
+
// Generic: show the tool name and a clipped scalar arg if there's an
|
|
166
|
+
// obvious one, else just the name.
|
|
167
|
+
const firstScalar = Object.values(obj).find((v) => typeof v === "string" || typeof v === "number");
|
|
168
|
+
return firstScalar !== undefined ? `${name}(${clip(String(firstScalar))})` : name;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Render ONE parsed Claude `--output-format stream-json` event into transcript
|
|
174
|
+
* lines (possibly empty). We render off the *complete* `assistant` events (not
|
|
175
|
+
* the partial `stream_event` deltas) so each block is whole; everything else —
|
|
176
|
+
* `system`/`stream_event`/`rate_limit_event`/`user` tool-result bodies — is
|
|
177
|
+
* dropped as noise. The final `result` event is summarized as a dim footer.
|
|
178
|
+
*/
|
|
179
|
+
export function renderClaudeEvent(event) {
|
|
180
|
+
if (!event || typeof event !== "object")
|
|
181
|
+
return [];
|
|
182
|
+
const e = event;
|
|
183
|
+
const type = e["type"];
|
|
184
|
+
if (type === "assistant") {
|
|
185
|
+
const message = e["message"];
|
|
186
|
+
const content = Array.isArray(message?.["content"]) ? message["content"] : [];
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const block of content) {
|
|
189
|
+
if (block["type"] === "text" && typeof block["text"] === "string") {
|
|
190
|
+
const text = block["text"].trim();
|
|
191
|
+
if (text)
|
|
192
|
+
out.push(...indentBlock(text));
|
|
193
|
+
}
|
|
194
|
+
else if (block["type"] === "tool_use" && typeof block["name"] === "string") {
|
|
195
|
+
const name = block["name"];
|
|
196
|
+
out.push(toolLine(name, summarizeTool(name, block["input"])));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
if (type === "result") {
|
|
202
|
+
const isError = e["is_error"] === true;
|
|
203
|
+
const turns = typeof e["num_turns"] === "number" ? e["num_turns"] : undefined;
|
|
204
|
+
const ms = typeof e["duration_ms"] === "number" ? e["duration_ms"] : undefined;
|
|
205
|
+
const meta = [
|
|
206
|
+
turns !== undefined ? `${turns} turn${turns === 1 ? "" : "s"}` : undefined,
|
|
207
|
+
ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : undefined,
|
|
208
|
+
].filter((b) => b !== undefined);
|
|
209
|
+
return [transcriptFooter(isError, meta)];
|
|
210
|
+
}
|
|
211
|
+
// system / stream_event / rate_limit_event / user tool-results → noise.
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Render ONE parsed Codex `exec --json` event (JSONL) into transcript lines.
|
|
216
|
+
* Codex emits `thread.started` / `turn.started` / `item.*` / `turn.completed` /
|
|
217
|
+
* `error`. We surface assistant messages, command/file-change items, and a dim
|
|
218
|
+
* completion/error footer; thread/turn lifecycle + token metadata are dropped.
|
|
219
|
+
*/
|
|
220
|
+
export function renderCodexEvent(event) {
|
|
221
|
+
if (!event || typeof event !== "object")
|
|
222
|
+
return [];
|
|
223
|
+
const e = event;
|
|
224
|
+
const type = e["type"];
|
|
225
|
+
if (typeof type !== "string")
|
|
226
|
+
return [];
|
|
227
|
+
if (type === "error") {
|
|
228
|
+
const msg = typeof e["message"] === "string" ? e["message"] : "unknown error";
|
|
229
|
+
return [transcriptFooter(true, [clip(msg, 120)])];
|
|
230
|
+
}
|
|
231
|
+
if (type === "turn.failed") {
|
|
232
|
+
const err = e["error"];
|
|
233
|
+
const msg = typeof err?.["message"] === "string" ? err["message"] : "turn failed";
|
|
234
|
+
return [transcriptFooter(true, [clip(msg, 120)])];
|
|
235
|
+
}
|
|
236
|
+
if (type === "turn.completed") {
|
|
237
|
+
return [transcriptFooter(false, [])];
|
|
238
|
+
}
|
|
239
|
+
// Codex item events carry the actual work. Shape:
|
|
240
|
+
// {"type":"item.completed","item":{"type":"agent_message"|"command_execution"
|
|
241
|
+
// |"file_change"|"reasoning",...}}
|
|
242
|
+
if (type === "item.completed" || type === "item.started") {
|
|
243
|
+
const item = e["item"];
|
|
244
|
+
if (!item)
|
|
245
|
+
return [];
|
|
246
|
+
const itemType = item["type"];
|
|
247
|
+
if (itemType === "agent_message") {
|
|
248
|
+
const text = typeof item["text"] === "string" ? item["text"].trim() : "";
|
|
249
|
+
// Only emit the full message once (on completion) to avoid duplicate prose.
|
|
250
|
+
return type === "item.completed" && text ? indentBlock(text) : [];
|
|
251
|
+
}
|
|
252
|
+
if (itemType === "command_execution") {
|
|
253
|
+
// Show the command when it starts; skip the completion echo.
|
|
254
|
+
if (type !== "item.started")
|
|
255
|
+
return [];
|
|
256
|
+
const cmd = typeof item["command"] === "string" ? item["command"] : undefined;
|
|
257
|
+
return cmd ? [toolLine("Bash", `Bash(${clip(cmd)})`)] : [];
|
|
258
|
+
}
|
|
259
|
+
if (itemType === "file_change") {
|
|
260
|
+
if (type !== "item.completed")
|
|
261
|
+
return [];
|
|
262
|
+
// Codex file_change items carry a `changes: [{path, kind}]` array; older
|
|
263
|
+
// shapes used a flat `path`/`file`. Summarize either form as an Edit line.
|
|
264
|
+
const changes = Array.isArray(item["changes"]) ? item["changes"] : [];
|
|
265
|
+
const paths = changes
|
|
266
|
+
.map((c) => (typeof c["path"] === "string" ? c["path"] : undefined))
|
|
267
|
+
.filter((p) => p !== undefined);
|
|
268
|
+
if (paths.length === 1)
|
|
269
|
+
return [toolLine("Edit", `Edit(${clip(paths[0])})`)];
|
|
270
|
+
if (paths.length > 1)
|
|
271
|
+
return [toolLine("Edit", `Edit(${paths.length} files)`)];
|
|
272
|
+
const flat = typeof item["path"] === "string"
|
|
273
|
+
? item["path"]
|
|
274
|
+
: typeof item["file"] === "string"
|
|
275
|
+
? item["file"]
|
|
276
|
+
: undefined;
|
|
277
|
+
return flat ? [toolLine("Edit", `Edit(${clip(flat)})`)] : [];
|
|
278
|
+
}
|
|
279
|
+
// reasoning / other item kinds → noise.
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
// thread.started / turn.started / token metadata → noise.
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Wrap a typed event renderer with JSON parsing. Non-JSON lines (rare stray
|
|
287
|
+
* stderr-on-stdout) are passed through verbatim so nothing is silently eaten,
|
|
288
|
+
* but empty/whitespace lines are dropped.
|
|
289
|
+
*/
|
|
290
|
+
function makeRenderer(render) {
|
|
291
|
+
return (jsonLine) => {
|
|
292
|
+
const trimmed = jsonLine.trim();
|
|
293
|
+
if (!trimmed)
|
|
294
|
+
return [];
|
|
295
|
+
let parsed;
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(trimmed);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Not JSON — likely a plain status line the agent wrote to stdout. Keep it
|
|
301
|
+
// rather than drop it, so unexpected output is still visible.
|
|
302
|
+
return [trimmed];
|
|
303
|
+
}
|
|
304
|
+
return render(parsed);
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
export const claudeRenderer = makeRenderer(renderClaudeEvent);
|
|
308
|
+
export const codexRenderer = makeRenderer(renderCodexEvent);
|
|
309
|
+
/**
|
|
310
|
+
* The dim footer that closes each agent's transcript. Done turns green with a
|
|
311
|
+
* check; an errored/aborted turn turns red with a cross. `meta` is the trailing
|
|
312
|
+
* detail (turns/duration, or an error message). Kept as a shared helper so the
|
|
313
|
+
* Claude `result` and Codex `turn.completed`/`error` footers render alike.
|
|
314
|
+
*
|
|
315
|
+
* NOTE: the literal words "done"/"error" are asserted by the renderer tests, so
|
|
316
|
+
* keep them as the leading token.
|
|
317
|
+
*/
|
|
318
|
+
export function transcriptFooter(isError, meta) {
|
|
319
|
+
const label = isError ? `${RED}✗ error${RESET}` : `${GREEN}✓ done${RESET}`;
|
|
320
|
+
const tail = meta.length > 0 ? `${DIM} · ${meta.join(" · ")}${RESET}` : "";
|
|
321
|
+
return `${label}${tail}`;
|
|
322
|
+
}
|
|
323
|
+
/** Width of the horizontal rules used to frame the daemon session + tasks. */
|
|
324
|
+
const RULE_WIDTH = 60;
|
|
325
|
+
const RULE = "─".repeat(RULE_WIDTH);
|
|
326
|
+
/**
|
|
327
|
+
* Heavy task header printed once when a task starts. The leading rule + blank
|
|
328
|
+
* line give multi-task runs clear visual separation (tasks stop blurring into
|
|
329
|
+
* one another); `index` is the 1-based run counter within this session.
|
|
330
|
+
*/
|
|
331
|
+
export function taskHeader(shortTaskId, title, agentName, index) {
|
|
332
|
+
const counter = index !== undefined ? `${BOLD}${CYAN}#${index}${RESET} ` : "";
|
|
333
|
+
return (`\n${DIM}${RULE}${RESET}\n` +
|
|
334
|
+
`${counter}${BOLD}▌ ${title}${RESET} ${DIM}(${shortTaskId} · ${agentName})${RESET}`);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The ZeNorm splash logo: the brand mark (the interlocking Z / hourglass from
|
|
338
|
+
* apps/web/public/zenorm-icon.svg) on the LEFT, the `ZeNorm` wordmark on the
|
|
339
|
+
* RIGHT. Rendered once at daemon launch.
|
|
340
|
+
*
|
|
341
|
+
* Built from a 7-row, solid-fill (`█`) pixel font — no box-drawing "shadow"
|
|
342
|
+
* frame (that earlier version rendered as illegible noise). The wordmark is
|
|
343
|
+
* mixed-case: `Z` and `N` are full 7-row caps; `e o r m` are lowercase glyphs
|
|
344
|
+
* that occupy the lower five rows (x-height — top two rows blank), so it reads
|
|
345
|
+
* as `ZeNorm`. Glyph widths vary (caps wider than the round lowercase); rows are
|
|
346
|
+
* concatenated with a 1-col gap.
|
|
347
|
+
*/
|
|
348
|
+
const LOGO_ROWS = 7;
|
|
349
|
+
/**
|
|
350
|
+
* 7-row glyphs for the wordmark. Each glyph's rows are equal width (the renderer
|
|
351
|
+
* pads with a trailing space); caps fill all 7 rows, lowercase the lower 5.
|
|
352
|
+
*/
|
|
353
|
+
const LOGO_GLYPHS = {
|
|
354
|
+
Z: ["██████", " ██", " ██ ", " ██ ", " ██ ", "██ ", "██████"],
|
|
355
|
+
e: [" ", " ", " ████ ", "██ ██", "██████", "██ ", " ████ "],
|
|
356
|
+
N: ["██ ██", "███ ██", "████ ██", "██ ████", "██ ███", "██ ██", "██ ██"],
|
|
357
|
+
o: [" ", " ", " ████ ", "██ ██", "██ ██", "██ ██", " ████ "],
|
|
358
|
+
r: [" ", " ", "██ ███", "███ ", "██ ", "██ ", "██ "],
|
|
359
|
+
m: [" ", " ", "███ ███ ", "██ ██ ██", "██ ██ ██", "██ ██ ██", "██ ██ ██"],
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* The brand mark — the actual `zenorm-icon.svg`, rasterized. Hand-drawn block
|
|
363
|
+
* ASCII of this hourglass read as mush at terminal sizes, so instead the SVG's
|
|
364
|
+
* alpha mask was rendered to a 25×16-pixel bitmap and packed into half-block
|
|
365
|
+
* cells (`▀`/`▄`/`█`): each character is two stacked pixels, so 9 text rows carry
|
|
366
|
+
* 18 px of vertical detail — the floor at which all four features (the two outer
|
|
367
|
+
* interlocking triangles AND the two nested solid wedges) survive; at 8 rows the
|
|
368
|
+
* inner wedges merge into the diagonals or drop out. Sized to roughly the
|
|
369
|
+
* wordmark's height (the 9-row icon is just one row taller than the 8-row
|
|
370
|
+
* wordmark, which is vertically centered beside it) so the mark and the `ZeNorm`
|
|
371
|
+
* text read at the same scale. The horizontal axis is pre-stretched ~1.2× (to
|
|
372
|
+
* undo the terminal's ~2:1 cell aspect without fattening the mark) so it keeps the
|
|
373
|
+
* SVG's taller-than-wide proportions. Regenerate from the SVG with
|
|
374
|
+
* `scripts/render-icon.py` if the brand mark changes. Every glyph here is width-1,
|
|
375
|
+
* so `visibleWidth`'s codepoint count stays exact for the splash layout.
|
|
376
|
+
*/
|
|
377
|
+
const LOGO_ICON = [
|
|
378
|
+
"▀█████████████████",
|
|
379
|
+
" ▀█▄ ▄▄▄▄ ▄██▀",
|
|
380
|
+
" ██▄ ▀▀ ▄██▀",
|
|
381
|
+
" ▀█▀ ▄██▀",
|
|
382
|
+
" ▄██▀",
|
|
383
|
+
" ▄██▀ ▄█▄",
|
|
384
|
+
" ██▀ ▄▄ ▀██",
|
|
385
|
+
" ▄█▀ ▀▀▀▀ ▀█▄",
|
|
386
|
+
"▄████████████████▄",
|
|
387
|
+
];
|
|
388
|
+
/** Render the wordmark `ZeNorm` as 7 parallel rows of glyph art. */
|
|
389
|
+
function wordmarkRows() {
|
|
390
|
+
const rows = Array.from({ length: LOGO_ROWS }, () => "");
|
|
391
|
+
for (const ch of "ZeNorm") {
|
|
392
|
+
const glyph = LOGO_GLYPHS[ch];
|
|
393
|
+
if (!glyph)
|
|
394
|
+
throw new Error(`No splash glyph defined for "${ch}".`);
|
|
395
|
+
for (let i = 0; i < LOGO_ROWS; i += 1)
|
|
396
|
+
rows[i] += `${glyph[i]} `;
|
|
397
|
+
}
|
|
398
|
+
return rows;
|
|
399
|
+
}
|
|
400
|
+
/** Extrusion offset (cells) of the 3D outline behind the glyph faces. */
|
|
401
|
+
const SHADOW_DX = 1;
|
|
402
|
+
const SHADOW_DY = 1;
|
|
403
|
+
/**
|
|
404
|
+
* Box-drawing line for an outline cell, chosen from which of its 4 orthogonal
|
|
405
|
+
* neighbours are *also* outline cells, so the extruded silhouette connects into
|
|
406
|
+
* continuous strokes (── runs, │ verticals, ╭╮╰╯ corners) instead of dots. Keyed
|
|
407
|
+
* by a 4-bit mask: up=8, down=4, left=2, right=1.
|
|
408
|
+
*/
|
|
409
|
+
const OUTLINE_LINES = {
|
|
410
|
+
0: "·", 1: "─", 2: "─", 3: "─", 4: "│", 5: "╭", 6: "╮", 7: "┬",
|
|
411
|
+
8: "│", 9: "╰", 10: "╯", 11: "┴", 12: "│", 13: "├", 14: "┤", 15: "┼",
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* Composite the logo art into colored rows with a Qwen-style extruded 3D edge:
|
|
415
|
+
* solid bright-cyan glyph faces, plus a teal line outline tracing the glyph
|
|
416
|
+
* silhouette one cell down-right. `art` rows are plain `█`/space strings.
|
|
417
|
+
*
|
|
418
|
+
* The face is every `█` cell. The outline is every cell that is empty in the
|
|
419
|
+
* face layer but filled in the face layer shifted up-left by the extrusion
|
|
420
|
+
* offset — i.e. the bottom-right contour that an extrusion would expose. Each
|
|
421
|
+
* outline cell picks a connecting box-drawing glyph from its outline neighbours.
|
|
422
|
+
*/
|
|
423
|
+
function extrudeRows(art) {
|
|
424
|
+
const width = Math.max(...art.map((r) => r.length));
|
|
425
|
+
const face = art.map((row) => [...row.padEnd(width)].map((c) => c === "█"));
|
|
426
|
+
const cell = (x, y) => y >= 0 && y < face.length && x >= 0 && x < width && face[y][x] === true;
|
|
427
|
+
// Outline cell: empty in the face, but the source cell (offset up-left) is a
|
|
428
|
+
// face — the silhouette the extrusion reveals to the down-right.
|
|
429
|
+
const isOutline = (x, y) => !cell(x, y) && cell(x - SHADOW_DX, y - SHADOW_DY);
|
|
430
|
+
const outWidth = width + SHADOW_DX;
|
|
431
|
+
const outHeight = art.length + SHADOW_DY;
|
|
432
|
+
const rows = [];
|
|
433
|
+
for (let y = 0; y < outHeight; y += 1) {
|
|
434
|
+
let line = "";
|
|
435
|
+
let style = "";
|
|
436
|
+
const setStyle = (next) => {
|
|
437
|
+
if (next !== style) {
|
|
438
|
+
line += RESET + next;
|
|
439
|
+
style = next;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
for (let x = 0; x < outWidth; x += 1) {
|
|
443
|
+
if (cell(x, y)) {
|
|
444
|
+
setStyle(`${BOLD}${BRIGHT_CYAN}`);
|
|
445
|
+
line += "█";
|
|
446
|
+
}
|
|
447
|
+
else if (isOutline(x, y)) {
|
|
448
|
+
setStyle(OUTLINE);
|
|
449
|
+
const mask = (isOutline(x, y - 1) ? 8 : 0) |
|
|
450
|
+
(isOutline(x, y + 1) ? 4 : 0) |
|
|
451
|
+
(isOutline(x - 1, y) ? 2 : 0) |
|
|
452
|
+
(isOutline(x + 1, y) ? 1 : 0);
|
|
453
|
+
line += OUTLINE_LINES[mask];
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
setStyle("");
|
|
457
|
+
line += " ";
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
rows.push(line + RESET);
|
|
461
|
+
}
|
|
462
|
+
return rows;
|
|
463
|
+
}
|
|
464
|
+
/** The icon's printed width (its widest row), for padding the splash gutter. */
|
|
465
|
+
const ICON_WIDTH = Math.max(...LOGO_ICON.map((r) => [...r].length));
|
|
466
|
+
/**
|
|
467
|
+
* Render the rasterized brand mark in brand cyan. Each row is right-padded to the
|
|
468
|
+
* icon's full width (so the gutter to the wordmark stays aligned), then the whole
|
|
469
|
+
* row is wrapped in one color run — the half-block glyphs are the ink, the spaces
|
|
470
|
+
* are transparent background, so a single SGR span per line both colors the mark
|
|
471
|
+
* and keeps the escape count tiny. Under NO_COLOR the span collapses to "" and
|
|
472
|
+
* the half-blocks still draw.
|
|
473
|
+
*/
|
|
474
|
+
function iconRows() {
|
|
475
|
+
return LOGO_ICON.map((row) => {
|
|
476
|
+
const padded = [...row].join("").padEnd(ICON_WIDTH);
|
|
477
|
+
return `${BOLD}${BRIGHT_CYAN}${padded}${RESET}`;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The full multi-line splash logo (no surrounding blank lines): the rasterized
|
|
482
|
+
* brand mark on the left (brand-cyan half-blocks), the `ZeNorm` wordmark on the
|
|
483
|
+
* right (3D-extruded, 5-space gutter). The icon stands one row taller than the
|
|
484
|
+
* wordmark (9 vs 8), so the shorter wordmark is vertically centered against it
|
|
485
|
+
* with blank padding. Under NO_COLOR the color escapes collapse to "" (the
|
|
486
|
+
* half-block mark and the wordmark's box outline still draw). Exported so it can
|
|
487
|
+
* be rendered/asserted on its own.
|
|
488
|
+
*/
|
|
489
|
+
export function splashLogo() {
|
|
490
|
+
const icon = iconRows();
|
|
491
|
+
const word = extrudeRows(wordmarkRows());
|
|
492
|
+
const blankIconRow = " ".repeat(ICON_WIDTH);
|
|
493
|
+
const height = Math.max(icon.length, word.length);
|
|
494
|
+
// Center the shorter block vertically against the taller one.
|
|
495
|
+
const wordPad = Math.floor((height - word.length) / 2);
|
|
496
|
+
const rows = [];
|
|
497
|
+
for (let i = 0; i < height; i += 1) {
|
|
498
|
+
const left = icon[i] ?? blankIconRow;
|
|
499
|
+
const wordIdx = i - wordPad;
|
|
500
|
+
const right = wordIdx >= 0 && wordIdx < word.length ? word[wordIdx] : "";
|
|
501
|
+
rows.push(` ${left} ${right}`);
|
|
502
|
+
}
|
|
503
|
+
return rows.join("\n");
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Frame a block of already-styled lines in a rounded box (Qwen-style metadata
|
|
507
|
+
* panel). `lines` may contain ANSI escapes; padding aligns on visible width. One
|
|
508
|
+
* cell of inner padding on each side. Under NO_COLOR the border greys collapse
|
|
509
|
+
* but the box-drawing characters still render, so the frame survives.
|
|
510
|
+
*/
|
|
511
|
+
function metaBox(lines) {
|
|
512
|
+
const inner = Math.max(...lines.map(visibleWidth));
|
|
513
|
+
const horizontal = "─".repeat(inner + 2);
|
|
514
|
+
const out = [`${BORDER}╭${horizontal}╮${RESET}`];
|
|
515
|
+
for (const line of lines) {
|
|
516
|
+
const fill = " ".repeat(inner - visibleWidth(line));
|
|
517
|
+
out.push(`${BORDER}│${RESET} ${line}${fill} ${BORDER}│${RESET}`);
|
|
518
|
+
}
|
|
519
|
+
out.push(`${BORDER}╰${horizontal}╯${RESET}`);
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The startup banner for the daemon. Shown once at launch (not just under
|
|
524
|
+
* `--dry-run`) so a real run announces what it's doing instead of sitting
|
|
525
|
+
* silently until the first task lands. Leads with the ZeNorm splash logo, then
|
|
526
|
+
* the run parameters.
|
|
527
|
+
*/
|
|
528
|
+
export function daemonBanner(opts) {
|
|
529
|
+
const mode = [opts.once ? "once" : undefined, opts.dryRun ? "dry-run" : undefined]
|
|
530
|
+
.filter((m) => m !== undefined)
|
|
531
|
+
.join(", ");
|
|
532
|
+
const modeSuffix = mode ? ` ${DIM}(${mode})${RESET}` : "";
|
|
533
|
+
// The metadata sits in a rounded box beside the same `>_ ZeNorm` header
|
|
534
|
+
// treatment Qwen uses, with the run parameters under it.
|
|
535
|
+
const box = metaBox([
|
|
536
|
+
`${BOLD}${BRIGHT_CYAN}>_${RESET} ${BOLD}ZeNorm${RESET}${modeSuffix}`,
|
|
537
|
+
`${DIM}spec-authoring agent · claim-and-run daemon${RESET}`,
|
|
538
|
+
"",
|
|
539
|
+
`${DIM}agent ${RESET}${opts.agent}`,
|
|
540
|
+
`${DIM}repo ${RESET}${opts.repo}`,
|
|
541
|
+
`${DIM}interval ${RESET}${opts.intervalSeconds}s`,
|
|
542
|
+
]);
|
|
543
|
+
return [
|
|
544
|
+
"",
|
|
545
|
+
splashLogo(),
|
|
546
|
+
"",
|
|
547
|
+
...box,
|
|
548
|
+
`${DIM} Polling for ready tasks — Ctrl-C to stop.${RESET}`,
|
|
549
|
+
].join("\n");
|
|
550
|
+
}
|
|
551
|
+
/** A spinner-free, dim status line for the daemon's idle/lifecycle states. */
|
|
552
|
+
export function statusLine(message) {
|
|
553
|
+
return `${DIM}· ${message}${RESET}`;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The per-task outcome line printed after a task's transcript closes: a bold
|
|
557
|
+
* green check for success or a bold red cross for failure, with the title, id,
|
|
558
|
+
* and wall-clock duration. This is the daemon's own verdict (distinct from the
|
|
559
|
+
* agent's transcript footer) so the run log reads as a sequence of outcomes.
|
|
560
|
+
*/
|
|
561
|
+
export function taskOutcome(opts) {
|
|
562
|
+
const seconds = (opts.durationMs / 1000).toFixed(1);
|
|
563
|
+
const mark = opts.ok ? `${GREEN}${BOLD}✓${RESET}` : `${RED}${BOLD}✗${RESET}`;
|
|
564
|
+
const verb = opts.ok ? `${GREEN}completed${RESET}` : `${RED}failed${RESET}`;
|
|
565
|
+
return `${mark} ${verb} ${opts.title} ${DIM}(${opts.shortTaskId} · ${seconds}s)${RESET}`;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* A loud, framed failure banner for a task that threw. Spells out which task
|
|
569
|
+
* failed, the error, and that it was released back to `todo` so the failure is
|
|
570
|
+
* impossible to miss in a scrolling daemon log (vs. a single dim line).
|
|
571
|
+
*/
|
|
572
|
+
export function failureBanner(opts) {
|
|
573
|
+
return [
|
|
574
|
+
`${RED}${BOLD}✗ task failed${RESET} ${opts.title} ${DIM}(${opts.shortTaskId})${RESET}`,
|
|
575
|
+
`${RED} ${opts.error}${RESET}`,
|
|
576
|
+
`${DIM} released back to todo — it can be re-claimed.${RESET}`,
|
|
577
|
+
].join("\n");
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* The session summary printed when the daemon shuts down (Ctrl-C or `--once`
|
|
581
|
+
* exhausted). Closes the run with a rule + a one-line tally so the user sees the
|
|
582
|
+
* total instead of having to count outcome lines by eye.
|
|
583
|
+
*/
|
|
584
|
+
export function sessionSummary(opts) {
|
|
585
|
+
const parts = [`${GREEN}${opts.completed} completed${RESET}`];
|
|
586
|
+
if (opts.failed > 0)
|
|
587
|
+
parts.push(`${RED}${opts.failed} failed${RESET}`);
|
|
588
|
+
const tally = opts.completed === 0 && opts.failed === 0 ? `${DIM}no tasks run${RESET}` : parts.join(`${DIM} · ${RESET}`);
|
|
589
|
+
return `\n${DIM}${RULE}${RESET}\n${BOLD}◣ session ended${RESET} ${DIM}·${RESET} ${tally}`;
|
|
590
|
+
}
|