agent-dag 3.22.1 → 3.22.4

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 (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,686 +0,0 @@
1
- // Everything the deck knows about the terminal it is printing into.
2
- //
3
- // The terminal is the first surface of this product and, for the seconds before
4
- // the browser opens, the only one — so it gets the same treatment the canvas
5
- // got. The rules below are the whole of it: detect what this terminal can do,
6
- // then degrade, rather than picking the floor and printing the same 16 colours
7
- // into a pipe, a CI log and a truecolor emulator alike.
8
- //
9
- // It lives here, apart from bin/deck.js, for the reason supervisor.mjs and
10
- // npx.mjs do: bin/deck.js installs hooks and binds a port the moment it is
11
- // imported, so none of this could be checked any other way — and "what does the
12
- // output look like at 40 columns, with NO_COLOR, on a Windows console" is
13
- // exactly the kind of question that is only ever answered by asserting it.
14
- //
15
- // Nothing here writes anything or reads process state on its own: every input
16
- // (env, isTTY, platform, columns) is a parameter, defaulted from the real thing.
17
- // The two exceptions are the cursor constants, which are strings the caller
18
- // writes, because who restores the cursor and when is a lifecycle question and
19
- // belongs with the lifecycle.
20
-
21
- // ── colour profile ───────────────────────────────────────────────────────────
22
- //
23
- // Detected once, in the order the conventions themselves establish:
24
- // NO_COLOR (no-color.org — present and non-empty means no colour, whatever the
25
- // value) beats FORCE_COLOR beats what the terminal advertises. Truecolor is
26
- // effectively universal now, and it is the only tier on which the deck's accent
27
- // is actually the deck's accent rather than whatever the user's theme decided
28
- // cyan is — so it is worth asking for, and worth degrading from cleanly.
29
-
30
- /** Present and non-empty, which is what every one of these variables means. */
31
- const set = (v) => v != null && v !== "";
32
-
33
- /** Anything below a space, or DEL. A URL is going inside an escape sequence, so
34
- * a control character in it would close that sequence early. */
35
- const hasControl = (s) => [...String(s)].some((c) => c.charCodeAt(0) <= 0x20 || c.charCodeAt(0) === 0x7f);
36
-
37
- /** Terminals that do truecolor without saying so in COLORTERM. */
38
- const TRUECOLOR_PROGRAMS = new Set(["iTerm.app", "vscode", "WezTerm", "ghostty", "Hyper", "rio", "Tabby"]);
39
-
40
- /** FORCE_COLOR's levels, as every CLI that honours it reads them. `undefined`
41
- * means the variable is absent and detection continues. */
42
- function forcedProfile(raw) {
43
- if (raw == null) return undefined;
44
- const v = String(raw).trim().toLowerCase();
45
- if (v === "0" || v === "false") return "none";
46
- if (v === "2") return "ansi256";
47
- if (v === "3") return "truecolor";
48
- return "ansi16"; // "", "1", "true", anything else: colour, at the safe floor
49
- }
50
-
51
- /**
52
- * What this terminal can be asked for: "none" | "ansi16" | "ansi256" | "truecolor".
53
- *
54
- * A Windows TTY floors at ansi16 rather than none: libuv translates SGR into
55
- * console API calls for consoles that cannot parse escapes themselves, so the
56
- * 16 colours arrive on legacy conhost too. Nothing above that tier is assumed
57
- * there — Windows Terminal says so through WT_SESSION, and conhost never does.
58
- */
59
- export function colorProfile({ env = process.env, isTTY = false, platform = process.platform } = {}) {
60
- if (set(env.NO_COLOR)) return "none";
61
- const forced = forcedProfile(env.FORCE_COLOR);
62
- if (forced !== undefined) return forced;
63
- if (!isTTY) return "none";
64
-
65
- const term = String(env.TERM ?? "").toLowerCase();
66
- if (term === "dumb") return "none";
67
-
68
- const colorterm = String(env.COLORTERM ?? "").toLowerCase();
69
- if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
70
- if (set(env.WT_SESSION)) return "truecolor";
71
- if (TRUECOLOR_PROGRAMS.has(String(env.TERM_PROGRAM ?? ""))) return "truecolor";
72
- if (term === "xterm-kitty" || term === "alacritty" || term.includes("truecolor")) return "truecolor";
73
- if (term.includes("256")) return "ansi256";
74
- // A TTY that advertises nothing still takes colour on every platform we
75
- // support; 16 is the tier nothing has to advertise to have.
76
- return "ansi16";
77
- }
78
-
79
- // ── colours ──────────────────────────────────────────────────────────────────
80
-
81
- /** rgb → the nearest xterm-256 index: the 6×6×6 cube, or the 24-step grey ramp
82
- * for anything neutral, which is where the muted tones land. */
83
- function to256(r, g, b) {
84
- if (r === g && g === b) {
85
- if (r < 8) return 16;
86
- if (r > 248) return 231;
87
- return Math.round(((r - 8) / 247) * 24) + 232;
88
- }
89
- return 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5);
90
- }
91
-
92
- /**
93
- * One colour, expressed as far as this profile reaches.
94
- *
95
- * `fallback` is the 16-colour SGR to use when that is all there is — chosen per
96
- * colour rather than derived, because the nearest of eight hues to a pastel is
97
- * a question with a taste answer, not an arithmetic one.
98
- */
99
- export function fg([r, g, b], profile, fallback) {
100
- if (profile === "truecolor") return `\x1b[38;2;${r};${g};${b}m`;
101
- if (profile === "ansi256") return `\x1b[38;5;${to256(r, g, b)}m`;
102
- if (profile === "ansi16") return fallback;
103
- return "";
104
- }
105
-
106
- // The canvas' own tokens (src/web/styles.css), so the terminal and the page are
107
- // one product rather than two. The ramp is the wordmark's vertical gradient:
108
- // lit at the top, settling into the deeper tone at the baseline.
109
- const BRAND = {
110
- accentSoft: [[186, 230, 253], "\x1b[96m"],
111
- accent: [[125, 211, 252], "\x1b[96m"],
112
- accentDeep: [[56, 189, 248], "\x1b[36m"],
113
- ok: [[134, 239, 172], "\x1b[32m"],
114
- warn: [[252, 211, 77], "\x1b[33m"],
115
- err: [[252, 165, 165], "\x1b[31m"],
116
- muted: [[126, 130, 140], "\x1b[2m"],
117
- };
118
-
119
- /**
120
- * The semantic names every call site uses — no raw escape ever appears in
121
- * bin/deck.js again. Empty strings under "none", so the same template literals
122
- * produce escape-free output in a pipe without a branch at each one.
123
- */
124
- export function palette(profile) {
125
- const on = profile !== "none";
126
- const out = {
127
- reset: on ? "\x1b[0m" : "",
128
- bold: on ? "\x1b[1m" : "",
129
- dim: on ? "\x1b[2m" : "",
130
- };
131
- for (const [name, [rgb, fallback]] of Object.entries(BRAND)) out[name] = fg(rgb, profile, fallback);
132
- return out;
133
- }
134
-
135
- // ── hyperlinks ───────────────────────────────────────────────────────────────
136
- //
137
- // OSC 8 is an allowlist rather than a probe: a terminal that does not know the
138
- // sequence is supposed to ignore it, and most do, but "most" is not a promise
139
- // worth making to a legacy Windows console — and tmux below 3.4 passes the
140
- // escape through mangled, which is worse than plain text. So links are emitted
141
- // only where they are known to arrive.
142
-
143
- const HYPERLINK_PROGRAMS = new Set(["iTerm.app", "vscode", "WezTerm", "ghostty", "Hyper", "rio", "Tabby"]);
144
-
145
- export function supportsHyperlinks({ env = process.env, profile = "truecolor" } = {}) {
146
- // NO_COLOR takes hyperlinks with it: the convention is about decoration, and
147
- // an escape sequence wrapped around a path is decoration.
148
- if (profile === "none") return false;
149
- const term = String(env.TERM ?? "").toLowerCase();
150
- if (set(env.TMUX) || term.startsWith("screen")) return false;
151
- if (HYPERLINK_PROGRAMS.has(String(env.TERM_PROGRAM ?? ""))) return true;
152
- if (set(env.WT_SESSION)) return true;
153
- if (set(env.KITTY_WINDOW_ID) || term === "xterm-kitty") return true;
154
- if (set(env.KONSOLE_VERSION) || set(env.DOMTERM)) return true;
155
- const vte = Number.parseInt(String(env.VTE_VERSION ?? ""), 10);
156
- return Number.isFinite(vte) && vte >= 5000; // GNOME Terminal 3.26+
157
- }
158
-
159
- /** `text` as a link to `url`, or just `text`. BEL-terminated, which is the form
160
- * the widest set of terminals accepts. A url carrying a control character is
161
- * dropped rather than escaped — it could only have come from a path we should
162
- * not be linking anyway. */
163
- export function link(text, url, enabled) {
164
- if (!enabled || !url || hasControl(url)) return text;
165
- return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
166
- }
167
-
168
- // ── glyphs ───────────────────────────────────────────────────────────────────
169
- //
170
- // Windows Terminal renders all of these; `cmd.exe` on a non-UTF-8 code page
171
- // renders none of them, and this project's rule is that everything works on
172
- // Windows. So there are two tiers and every glyph the deck prints — punctuation
173
- // included, since an em dash is as absent from CP437 as a check mark — comes
174
- // from one of them.
175
-
176
- const UNICODE_GLYPHS = {
177
- ok: "✓", fail: "✗", warn: "⚠", stop: "◉", restart: "↻", cancel: "✕",
178
- up: "↑", play: "▶", pulse: "●",
179
- arrow: "→", ellipsis: "…", dash: "—", bullet: "·",
180
- };
181
-
182
- const ASCII_GLYPHS = {
183
- ok: "+", fail: "x", warn: "!", stop: "*", restart: "~", cancel: "x",
184
- up: "^", play: ">", pulse: "*",
185
- arrow: "->", ellipsis: "...", dash: "-", bullet: "-",
186
- };
187
-
188
- /**
189
- * Whether this terminal can be trusted with the box-drawing and arrow glyphs.
190
- *
191
- * Everywhere but Windows, yes — except the Linux virtual console, whose font is
192
- * 256 glyphs and none of them are these. On Windows it is the other way round:
193
- * an allowlist of the emulators that ship a real font and a UTF-8 code page,
194
- * because the default console does neither.
195
- */
196
- export function unicodeOK({ env = process.env, platform = process.platform } = {}) {
197
- const term = String(env.TERM ?? "").toLowerCase();
198
- if (platform !== "win32") return term !== "linux" && term !== "dumb";
199
- if (set(env.WT_SESSION)) return true;
200
- if (String(env.TERM_PROGRAM ?? "") === "vscode") return true;
201
- if (set(env.ConEmuTask) || set(env.WSL_DISTRO_NAME)) return true;
202
- if (String(env.TERMINAL_EMULATOR ?? "") === "JetBrains-JediTerm") return true;
203
- return term.startsWith("xterm") || term === "alacritty";
204
- }
205
-
206
- export function glyphs(unicode) {
207
- return unicode ? { ...UNICODE_GLYPHS } : { ...ASCII_GLYPHS };
208
- }
209
-
210
- // One definition, for the banner, the step spinner and anything later. The
211
- // braille frames are the nicest thing a terminal can do with one cell; the
212
- // ASCII tier gets the rotating bar, which is the only spinner CP437 has.
213
- const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
214
- const ASCII_FRAMES = ["|", "/", "-", "\\"];
215
-
216
- export function spinnerFrames(unicode) {
217
- return unicode ? BRAILLE_FRAMES.slice() : ASCII_FRAMES.slice();
218
- }
219
-
220
- /** When a spinner starts saying how long it has been going.
221
- *
222
- * #742: a spinner four seconds in looks exactly like one four hundred
223
- * milliseconds in, and that is the whole of "is this thing stuck". A number
224
- * answers it. Three seconds, because under that the number would be on screen
225
- * for a blink on every ordinary boot and would be noise rather than an answer
226
- * — nobody doubts a step that has not yet lasted as long as it takes to doubt
227
- * one. */
228
- export const SPINNER_ELAPSED_AFTER_MS = 3_000;
229
-
230
- /**
231
- * The seconds a spinner shows beside its label, or "" while it is too young to
232
- * have anything worth saying.
233
- *
234
- * Whole seconds, floored, and never a tenth: a number that changes ten times a
235
- * second is a second spinner rather than an answer about the first one. Here
236
- * rather than in bin/deck.js because that file runs a deck when it is imported,
237
- * and this is the one part of `step` worth holding still in a test.
238
- */
239
- export function elapsedSuffix(ms, after = SPINNER_ELAPSED_AFTER_MS) {
240
- return ms < after ? "" : ` ${Math.floor(ms / 1000)}s`;
241
- }
242
-
243
- /**
244
- * How long a deck has been up, in the two units that matter and no more.
245
- *
246
- * `--status` and `--stop` both print this, and the question behind it is always
247
- * "is this the deck I started, or one I forgot about" — which "3h 12m" answers
248
- * and "11543s" does not. Two units, because the second one stops being
249
- * interesting as soon as the first is large: nobody reading "2d" wants the
250
- * minutes.
251
- *
252
- * Seconds under a minute, because that is the unit a deck started moments ago
253
- * is measured in and "up just now" is not an answer to "how long". A clock that
254
- * went backwards, or a `startedAt` from a machine whose time has since been
255
- * corrected, reads as `0s` rather than as a negative duration.
256
- */
257
- export function sinceLabel(ms) {
258
- const s = Math.floor(Number(ms) / 1000);
259
- if (!Number.isFinite(s) || s < 0) return "0s";
260
- if (s < 60) return `${s}s`;
261
- const m = Math.floor(s / 60);
262
- if (m < 60) return `${m}m`;
263
- const h = Math.floor(m / 60);
264
- if (h < 24) return h > 0 && m % 60 > 0 ? `${h}h ${m % 60}m` : `${h}h`;
265
- const d = Math.floor(h / 24);
266
- return h % 24 > 0 ? `${d}d ${h % 24}h` : `${d}d`;
267
- }
268
-
269
- // ── motion ───────────────────────────────────────────────────────────────────
270
-
271
- /**
272
- * Whether anything is allowed to move — the terminal's `prefers-reduced-motion`.
273
- *
274
- * A pipe, a file, a CI log and a NO_COLOR terminal all get the same treatment:
275
- * no sleeps, no `\r` repaints, no spinner, no pulse. Every one of those either
276
- * cannot show motion or is somebody's log file, and a log file full of carriage
277
- * returns is the artefact this rule exists to prevent.
278
- */
279
- export function motionOK({ env = process.env, isTTY = false, profile = colorProfile({ env, isTTY }) } = {}) {
280
- if (!isTTY || profile === "none") return false;
281
- const ci = String(env.CI ?? "").trim().toLowerCase();
282
- if (set(ci) && ci !== "0" && ci !== "false") return false;
283
- return true;
284
- }
285
-
286
- // ── width ────────────────────────────────────────────────────────────────────
287
-
288
- /** The visible text: SGR colour and OSC 8 hyperlinks removed. Everything that
289
- * measures a line for layout measures it through here — a padded line that
290
- * counted its own escapes is how a `\r` repaint starts leaving debris. */
291
- export function stripAnsi(s) {
292
- return String(s)
293
- .replace(/\x1b\]8;;.*?(?:\x07|\x1b\\)/g, "")
294
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
295
- }
296
-
297
- export function visibleWidth(s) {
298
- return stripAnsi(s).length;
299
- }
300
-
301
- /** How wide the terminal is, or 80 when it will not say — a pipe, a CI runner,
302
- * a terminal that never sent SIGWINCH. 80 is the assumption every one of them
303
- * is already built around, and the layout below never needs more than 40. */
304
- export function termColumns(stream = process.stdout, env = process.env) {
305
- const c = stream?.columns;
306
- if (Number.isInteger(c) && c > 0) return c;
307
- // COLUMNS, the POSIX spelling of this question, and the only answer a DETACHED
308
- // deck has. Its stdout is the log file, so the stream knows nothing about a
309
- // width — but the launcher is watching that file from a real terminal and
310
- // passes its own width down, so the boot report is laid out for the terminal
311
- // a person is actually reading it in rather than for the 80 below.
312
- const n = Number.parseInt(String(env?.COLUMNS ?? "").trim(), 10);
313
- return Number.isInteger(n) && n > 0 ? n : 80;
314
- }
315
-
316
- /** The status column, computed from the labels rather than counted into each
317
- * string by hand. A label one character longer used to break the alignment of
318
- * every row silently. */
319
- export function labelColumn(labels) {
320
- return labels.reduce((w, l) => Math.max(w, String(l).length), 0);
321
- }
322
-
323
- /**
324
- * `s` trimmed to `max` columns, from whichever end matters least.
325
- *
326
- * A path, a URL or a version has no spaces in it and is identified by its tail
327
- * — `…/agent-dag/hook.js` still says which file — so the head goes. A sentence
328
- * is identified by its head, and `…nel enabled)` says nothing at all, so its
329
- * tail goes instead. Below the width where the ellipsis plus a few characters
330
- * would still mean something, the detail is dropped rather than reduced to
331
- * punctuation; the label beside it already says which row this is.
332
- */
333
- export function fit(s, max, ellipsis = "…") {
334
- const text = String(s);
335
- if (text.length <= max) return text;
336
- if (max < ellipsis.length + 3) return "";
337
- const room = max - ellipsis.length;
338
- return /\s/.test(text) ? text.slice(0, room) + ellipsis : ellipsis + text.slice(text.length - room);
339
- }
340
-
341
- /**
342
- * A failure, reduced to the one line it is safe to print while the deck is
343
- * painting.
344
- *
345
- * Everything bin/deck.js writes after the banner is a `\r`-rewritten row — the
346
- * spinner in `step`, the pulse line, which repaints every 800ms — so a write
347
- * that arrives from an async callback lands in the middle of one of them. A
348
- * single short line is survivable: it ends in a newline, the next repaint
349
- * starts on a fresh row, and the report above it is still on screen. A
350
- * subprocess's stack trace is not: fifteen lines scroll the entire startup
351
- * report away and leave the pulse mid-frame. Reported from Windows (#432),
352
- * where a broken npm shim put two Node stack traces over the boot output.
353
- *
354
- * Three things happen here, and the order matters.
355
- *
356
- * The most informative line is chosen rather than the first one, because the
357
- * first line of a Node stack trace is `node:internal/modules/cjs/loader:1573`
358
- * and the line that names the actual failure — `Error: Cannot find module …` —
359
- * is five lines below it.
360
- *
361
- * Every control character is replaced, not just the newlines. A lone `\r` is a
362
- * cursor jump to column 0, so a CRLF stack trace that only had its `\n`
363
- * removed would still overwrite whatever the deck had drawn on that row — and
364
- * CRLF is what a Windows child writes, which is the platform this exists for.
365
- * An ESC would open an escape sequence out of a string the deck did not write.
366
- *
367
- * And the result is fitted to the room the caller has left, so the one line
368
- * stays one line: a message that wraps is two rows, and the `\r` that follows
369
- * only ever reaches the second of them. The ellipsis defaults to the ASCII one
370
- * rather than `…`, because callers of this are error paths in modules that
371
- * know nothing about the terminal's glyph tier — and a `…` on a cmd.exe code
372
- * page is a question mark in the middle of the only clue the user got.
373
- *
374
- * @param room how many columns are free after whatever prefix the caller prints.
375
- */
376
- export function oneLine(text, room = 80, ellipsis = "...") {
377
- const lines = stripAnsi(String(text ?? ""))
378
- .split("\n")
379
- // eslint-disable-next-line no-control-regex
380
- .map((l) => l.replace(/[\x00-\x1f\x7f]/g, " ").trim())
381
- .filter(Boolean);
382
- if (!lines.length) return "";
383
- // "Error:", "TypeError:", "Error [ERR_MODULE_NOT_FOUND]:" — the line a reader
384
- // would have picked out of the dump themselves. The optional prefix is what
385
- // makes a bare "Error:" match as well as a named subclass.
386
- const named = lines.find((l) => /^[\w$]*Error\b/.test(l));
387
- return fit(named ?? lines[0], Math.max(8, room), ellipsis);
388
- }
389
-
390
- const same = (s) => s;
391
-
392
- /**
393
- * One aligned status row: ` ✓ label detail`.
394
- *
395
- * Laid out on the plain text and painted afterwards, so colour and hyperlinks
396
- * never enter the arithmetic. The last cell of the line is left empty: a
397
- * terminal that fills its final column either wraps or leaves the cursor in a
398
- * place the next `\r` cannot recover from.
399
- *
400
- * `keep` is for the one detail nobody can act on a fragment of — the URL. When
401
- * it will not fit beside its label it moves to its own line under the gutter
402
- * rather than losing its port to an ellipsis; a truncated address is not a
403
- * shorter address, it is no address. The result then contains a newline, which
404
- * is why callers write it whole.
405
- */
406
- export function statusLine({
407
- mark = " ", label = "", detail = "", labelWidth = 0, columns = 80, indent = 2, paint = {},
408
- ellipsis = "…", keep = false,
409
- } = {}) {
410
- // A blank gutter is painted by nobody: colouring a space costs two escapes
411
- // and shows nothing, and rows with no mark are the majority of the quiet ones.
412
- const paintMark = stripAnsi(mark).trim() ? (paint.mark ?? same) : same;
413
- const paintLabel = paint.label ?? same;
414
- const paintDetail = paint.detail ?? same;
415
- const pad = " ".repeat(indent);
416
- const head = `${pad}${paintMark(mark)} ${paintLabel(label)}`;
417
-
418
- if (!detail) return head;
419
-
420
- const padded = label.padEnd(Math.max(labelWidth, label.length));
421
- const used = indent + stripAnsi(mark).length + 2 + padded.length + 2;
422
- const room = columns - used - 1;
423
- const plain = stripAnsi(detail);
424
- if (keep && plain.length > room) {
425
- // Under the gutter if that still fits, then flush left, then wrapped —
426
- // there is nothing else to try when the address is wider than the terminal.
427
- const under = [indent + 3, indent, 0].find(n => n + plain.length < columns) ?? 0;
428
- return `${head}\n${" ".repeat(under)}${paintDetail(detail)}`;
429
- }
430
- const fitted = plain.length <= room ? detail : fit(plain, room, ellipsis);
431
- if (!fitted) return head;
432
- return `${pad}${paintMark(mark)} ${paintLabel(padded)} ${paintDetail(fitted)}`;
433
- }
434
-
435
- // ── wordmark ─────────────────────────────────────────────────────────────────
436
- //
437
- // Drawn, not pasted out of a figlet font — six letters on a 4×6 pixel grid,
438
- // printed two pixel rows to a text row with the half-block characters, which is
439
- // what gives it a lowercase with real ascenders in three lines instead of six.
440
- // The old banner said `agents-deck`, a name the repo, the command, the README
441
- // and the docs stopped using; this one says what the product is called.
442
-
443
- const LETTERS = {
444
- c: ["....", "....", ".###", "#...", "#...", ".###"],
445
- d: ["...#", "...#", ".###", "#..#", "#..#", ".###"],
446
- e: ["....", "....", ".###", "####", "#...", ".###"],
447
- k: ["#...", "#...", "#.##", "##..", "#.#.", "#..#"],
448
- };
449
-
450
- /** Two pixel rows into one text row: ▀ upper only, ▄ lower only, █ both. */
451
- function squash(top, bottom) {
452
- let out = "";
453
- for (let i = 0; i < top.length; i++) {
454
- const t = top[i] === "#";
455
- const b = bottom[i] === "#";
456
- out += t && b ? "█" : t ? "▀" : b ? "▄" : " ";
457
- }
458
- return out;
459
- }
460
-
461
- function renderWord(word) {
462
- const rows = ["", "", ""];
463
- for (const [n, ch] of [...word].entries()) {
464
- const px = LETTERS[ch];
465
- for (let r = 0; r < 3; r++) rows[r] += (n ? " " : "") + squash(px[r * 2], px[r * 2 + 1]);
466
- }
467
- return rows.map((r) => r.replace(/\s+$/, ""));
468
- }
469
-
470
- /** The drawn mark itself, three text rows of half-blocks.
471
- *
472
- * Exported for one reader, and it is a test rather than a caller (#383). This
473
- * is the ONLY place the art's true width can be measured: `renderWord` trims
474
- * each row's trailing blanks, so the rows are not all the same length, and the
475
- * arithmetic in WORDMARK_WIDTH below is a hand-computed prediction of the
476
- * widest of them. Nothing in the banner path compares the two — `wordmark()`
477
- * gates on WORDMARK_WIDTH and then prints THESE lines — so an under-counting
478
- * width lets the art render into a terminal too narrow to hold it and the
479
- * three rows wrap into six. See wordmark-art-width.test.ts, which is the
480
- * comparison this export exists to make possible. */
481
- export const WORDMARK_LINES = renderWord("ccdeck");
482
- /** 29 columns of art, plus the two-space indent every other line uses. */
483
- export const WORDMARK_WIDTH = 2 + LETTERS.c[0].length * 6 + 5;
484
-
485
- /**
486
- * The tagline, longest version that fits — the name and version are the part
487
- * that must survive, the rest is context.
488
- *
489
- * `prefix` is how many columns are already spent on the line the tagline lands
490
- * on, and it is a parameter because the three layouts below do not agree on it
491
- * (#383). In the full banner the tagline gets a line of its own behind a
492
- * two-space indent; in the compact and plain ones it shares the line with the
493
- * product name, which costs eight or nine columns more. This budget was
494
- * hard-coded at 2 for all three, so between 21 and 31 columns — the widths where
495
- * the compact form is chosen and the medium tagline still looks affordable —
496
- * the banner printed a 36-column line into a terminal that could not hold it and
497
- * the first thing a user saw wrapped. `columns - 1` keeps the last cell empty,
498
- * because a line ending exactly at the right margin makes some terminals wrap
499
- * anyway.
500
- */
501
- function tagline(version, columns, bullet, prefix) {
502
- const v = `v${version}`;
503
- const options = [
504
- `${v} ${bullet} live agent DAG ${bullet} Claude Code + Codex`,
505
- `${v} ${bullet} live agent DAG`,
506
- v,
507
- ];
508
- return options.find((o) => o.length + prefix <= columns - 1) ?? v;
509
- }
510
-
511
- // What each layout spends before the tagline starts, counted off the strings
512
- // built in `wordmark` below: " ccdeck " for plain, " ccdeck " for compact,
513
- // and the bare two-space indent for the full banner's own tagline line.
514
- const PLAIN_PREFIX_W = " ccdeck ".length;
515
- const COMPACT_PREFIX_W = " ccdeck ".length;
516
- const TAG_INDENT_W = " ".length;
517
-
518
- /**
519
- * The wordmark, in the largest form this terminal can hold.
520
- *
521
- * "full" — the drawn mark, three rows, on a vertical gradient
522
- * "compact" — the name in the accent, for a terminal too narrow for the art
523
- * or a console without the half-block glyphs
524
- * "plain" — no escapes at all: a pipe, a CI log, NO_COLOR
525
- *
526
- * Returns the lines to print, blank lines included, so the caller writes them
527
- * and nothing else decides how much air the banner gets.
528
- */
529
- export function wordmark({
530
- columns = 80, version = "0.0.0", profile = "none", unicode = true, pal = palette(profile),
531
- } = {}) {
532
- const g = glyphs(unicode);
533
- // Each layout asks for its own tagline, because each leaves it a different
534
- // amount of room — see the prefix constants above.
535
- const tag = (prefix) => tagline(version, columns, g.bullet, prefix);
536
-
537
- if (profile === "none") return { kind: "plain", lines: ["", ` ccdeck ${tag(PLAIN_PREFIX_W)}`, ""] };
538
- if (!unicode || columns < WORDMARK_WIDTH + 1) {
539
- return { kind: "compact", lines: ["", ` ${pal.bold}${pal.accent}ccdeck${pal.reset} ${pal.muted}${tag(COMPACT_PREFIX_W)}${pal.reset}`, ""] };
540
- }
541
-
542
- const ramp = [pal.accentSoft, pal.accent, pal.accentDeep];
543
- return {
544
- kind: "full",
545
- lines: [
546
- "",
547
- ...WORDMARK_LINES.map((l, i) => ` ${ramp[i]}${l}${pal.reset}`),
548
- "",
549
- ` ${pal.muted}${tag(TAG_INDENT_W)}${pal.reset}`,
550
- "",
551
- ],
552
- };
553
- }
554
-
555
- // ── the pulse line ───────────────────────────────────────────────────────────
556
-
557
- /**
558
- * The one line that stays on screen for hours, sized to the terminal it is on.
559
- *
560
- * It is redrawn over itself with `\r`, so both messages are padded to one width
561
- * — the shorter has to cover the longer — and that width is clamped to the real
562
- * terminal: at 40 columns the old 58-character message wrapped, after which the
563
- * `\r` only ever reached the second row and every repaint left the first one
564
- * behind. A deck no hook can find is not listening in any sense the user cares
565
- * about, which is why that state has a message here at all.
566
- *
567
- * `claude` is what makes that last sentence conditional. A deck watching only
568
- * Codex is not waiting on a hook: startCodexWatcher is started by startServer
569
- * and never consults the discovery file, and writesCodexLog keeps a deck with no
570
- * record on disk writing its log. Capture and persistence both work perfectly
571
- * there, while this line used to repaint "hooks cannot find this deck" every
572
- * 800ms, forever (#404) — a permanent alarm about a mechanism that deck does not
573
- * use. The one-time report at boot still says what IS lost; see
574
- * unregisteredDetail.
575
- */
576
- export function pulseText({
577
- registered = true, claude = true, columns = 80, unicode = true, indent = 2, busy = null,
578
- } = {}) {
579
- const g = glyphs(unicode);
580
- const room = Math.max(4, columns - indent - 3 - 1);
581
- const pick = (options) => options.find((o) => o.length <= room) ?? options[options.length - 1].slice(0, room);
582
-
583
- const rest = pick([`listening ${g.dash} Ctrl+C to stop`, "listening"]);
584
- const bad = pick([
585
- `listening, but not registered ${g.dash} hooks cannot find this deck`,
586
- `not registered ${g.dash} hooks cannot find this deck`,
587
- "not registered",
588
- ]);
589
- // Both branches are still measured, registered or not, because the line is
590
- // redrawn over itself and the shorter message has to cover the longer one on
591
- // the beat after a deck loses its registration.
592
- //
593
- // The width is deliberately computed from the two FIXED messages only. `busy`
594
- // comes and goes on a single boot — an install starts, the line names it, the
595
- // install ends and the line goes back to Ctrl+C — so a width that grew to fit
596
- // the label would have to shrink again afterwards, and the shorter line would
597
- // leave the tail of the longer one on screen. Instead the label is shown only
598
- // where it already fits — 60 columns and wider, measured — and below that the
599
- // line says the true thing it has always said.
600
- const width = Math.min(room, Math.max(rest.length, bad.length));
601
- // What is still happening, rather than what is always true. `Ctrl+C to stop`
602
- // is the right thing to say to somebody with nothing left to wait for, and
603
- // the wrong thing to say to somebody watching an install.
604
- const label = typeof busy === "string" && busy.trim() ? busy.trim() : null;
605
- const working = label ? `listening ${g.bullet} ${label}` : null;
606
- const ok = working && working.length <= width ? working : rest;
607
- return (registered || !claude ? ok : bad).padEnd(width);
608
- }
609
-
610
- /**
611
- * Whether the line has anything left to say by moving.
612
- *
613
- * #742. The dot alternated green and grey every 800ms for as long as the deck
614
- * ran, and a blinking indicator beside a status line is the vocabulary of
615
- * "working on it" — so a boot that had finished in a second read as one that
616
- * never finished, and people said so. The deck's own web UI already retired
617
- * this once: the pill goes quiet at rest (#720). The terminal did not.
618
- *
619
- * Motion is now spent on the two states where something is genuinely
620
- * outstanding — a deck no hook can find, and a background job still running —
621
- * and nowhere else. At rest the dot is painted once, in the healthy colour, and
622
- * left alone. Movement then means something changed, which is the only thing
623
- * movement should ever mean on a line somebody leaves open for hours.
624
- */
625
- export function pulseMoves({ registered = true, claude = true, busy = null } = {}) {
626
- if (!registered && claude) return true;
627
- return typeof busy === "string" && busy.trim() !== "";
628
- }
629
-
630
- /**
631
- * Whether the dot is lit on this beat.
632
- *
633
- * `"on"` on every beat of a deck at rest, which is what makes the line still:
634
- * bin/deck.js paints a beat only when the frame differs from the one already on
635
- * screen, so a dot that is always lit is a line written once and then left
636
- * alone. Alternating is reserved for the states pulseMoves admits.
637
- *
638
- * The beat is a parameter rather than counted here so this is a function of its
639
- * inputs and nothing else — and so a test can ask what the twentieth beat of an
640
- * idle deck looks like without waiting sixteen seconds for it.
641
- */
642
- export function pulseDot(beat, { registered = true, claude = true, busy = null } = {}) {
643
- if (!pulseMoves({ registered, claude, busy })) return "on";
644
- return beat % 2 === 0 ? "on" : "off";
645
- }
646
-
647
- /**
648
- * The second line of the "not registered" report, which bin/deck.js prints once
649
- * per change of state rather than every beat.
650
- *
651
- * What a missing discovery file costs is not the same on the two capture paths,
652
- * and the old sentence — "hooks find this deck through <file>, so until that
653
- * file exists no events arrive" — stated the Claude Code answer as if it were
654
- * both. On a Codex-only deck it is false twice over: no hook is looking for this
655
- * deck, and events do keep arriving, because the rollout watcher reads the files
656
- * directly. What that deck really loses is the writer election in
657
- * log-writer.mjs, which is how several decks tailing one rollout agree on which
658
- * of them appends it to a shared events log. A deck with no record on disk is
659
- * assumed to be writing, so nothing is dropped — the log can gain the same line
660
- * twice instead.
661
- *
662
- * @param file the discovery file that could not be written.
663
- * @param claude whether this deck is watching Claude Code at all.
664
- * @param dash the em dash, or the ASCII stand-in on a console that cannot draw
665
- * one (#797). A parameter for the same reason `renameNotice` takes one: this
666
- * module's own header says "every glyph the deck prints — punctuation
667
- * included, since an em dash is as absent from CP437 as a check mark — comes
668
- * from one of them", and this sentence had it hardcoded. On a legacy cmd.exe
669
- * or the Linux virtual console the reader got a box in the middle of the one
670
- * line explaining why no events are arriving.
671
- */
672
- export function unregisteredDetail({ file, claude = true, dash = "—" }) {
673
- if (claude) {
674
- return `Claude Code hooks find this deck through ${file}, so until that file exists no Claude Code events arrive.`;
675
- }
676
- return `Codex capture does not use ${file} ${dash} the deck tails the rollout files itself ${dash} so events still arrive. Only decks sharing one events log need it, to agree on which of them records.`;
677
- }
678
-
679
- // ── cursor ───────────────────────────────────────────────────────────────────
680
- //
681
- // Strings rather than writers: hiding the cursor is trivial and restoring it is
682
- // not — it has to happen on the normal exit, on SIGINT/SIGTERM/SIGHUP and after
683
- // an uncaught throw, and getting that half-right leaves the user's shell with
684
- // no cursor after the deck is gone. The lifecycle owns it; see bin/deck.js.
685
- export const CURSOR_HIDE = "\x1b[?25l";
686
- export const CURSOR_SHOW = "\x1b[?25h";