agent-dag 1.33.127 → 1.33.129

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/bin/agent-dag.js CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  npxRestartSpec, readRestartFailure, recordRestartFailure,
42
42
  } from "../src/server/self-update.mjs";
43
43
  import { dieOfSignal, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
44
+ import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
44
45
 
45
46
  const BIN_DIR = dirname(fileURLToPath(import.meta.url));
46
47
  const WORKER = join(BIN_DIR, "deck.js");
@@ -48,6 +49,15 @@ const PKG_ROOT = dirname(BIN_DIR);
48
49
 
49
50
  const VERSION = installedVersion(PKG_ROOT) ?? "?";
50
51
 
52
+ // The supervisor prints exactly one line of its own — the fetch — and it has to
53
+ // look like it came from the same product as the worker's rows. That is all the
54
+ // presentation this file gets: it is the process that is never replaced, and a
55
+ // colour profile plus a glyph tier is the most it can carry without becoming
56
+ // something that can fail. Everything else it says is an error, on stderr, in
57
+ // plain text, where it belongs.
58
+ const P = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
59
+ const G = glyphs(unicodeOK());
60
+
51
61
  // Who the restart-failure note below belongs to. Several decks of the same
52
62
  // package run out of one home directory — two `npx ccdeck` runs even share the
53
63
  // _npx directory and therefore the version — so a note named after the package
@@ -228,7 +238,7 @@ async function prefetchUpgrade(worker) {
228
238
  }
229
239
 
230
240
  attempting = { pkgName, spec, target, attempt: decision.attempt };
231
- process.stdout.write(`\n fetching ${spec}…\n`);
241
+ process.stdout.write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}fetching ${spec}${G.ellipsis}${P.reset}\n`);
232
242
  const got = await npxPrefetch(spec, { onChild: (c) => { fetching = c; } });
233
243
  fetching = null;
234
244
  // Ctrl+C, or a worker that died on its own while npm was working: either way
package/bin/deck.js CHANGED
@@ -8,6 +8,11 @@ import { resolve, dirname, join } from "node:path";
8
8
  import { homedir } from "node:os";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
+ import { dieOfSignal } from "../src/server/supervisor.mjs";
12
+ import {
13
+ CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, palette,
14
+ pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK, wordmark,
15
+ } from "../src/server/term.mjs";
11
16
 
12
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
18
  const PKG_ROOT = resolve(__dirname, "..");
@@ -101,181 +106,244 @@ if (!existsSync(WEB_DIST)) {
101
106
  process.exit(1);
102
107
  }
103
108
 
104
- // ── ANSI helpers ──────────────────────────────────────────────────────────────
105
- const tty = process.stdout.isTTY;
106
- const C = {
107
- reset: tty ? "\x1b[0m" : "",
108
- bold: tty ? "\x1b[1m" : "",
109
- dim: tty ? "\x1b[2m" : "",
110
- cyan: tty ? "\x1b[36m" : "",
111
- blue: tty ? "\x1b[34m" : "",
112
- magenta: tty ? "\x1b[35m" : "",
113
- yellow: tty ? "\x1b[33m" : "",
114
- green: tty ? "\x1b[32m" : "",
115
- white: tty ? "\x1b[97m" : "",
116
- bCyan: tty ? "\x1b[96m" : "",
117
- bMag: tty ? "\x1b[95m" : "",
118
- };
109
+ // ── the terminal we are printing into ─────────────────────────────────────────
110
+ // Asked once, degraded from there — see src/server/term.mjs, which is where all
111
+ // of this is decided and asserted. Below this point the deck writes no escape of
112
+ // its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
113
+ // is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
114
+ // question rather than thirty separate ones nobody remembers to ask.
115
+ const tty = Boolean(process.stdout.isTTY);
116
+ const PROFILE = colorProfile({ isTTY: tty });
117
+ const P = palette(PROFILE);
118
+ const UNICODE = unicodeOK();
119
+ const G = glyphs(UNICODE);
120
+ const LINKS = supportsHyperlinks({ profile: PROFILE });
121
+ // The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
122
+ // pipe, under CI, or with NO_COLOR set.
123
+ const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
124
+ const write = (s) => process.stdout.write(s);
125
+ // Read per line, never cached: a terminal can be resized while the deck runs,
126
+ // and the pulse below is still on screen hours later.
127
+ const cols = () => termColumns(process.stdout);
119
128
  const sleep = ms => new Promise(r => setTimeout(r, ms));
129
+ const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
130
+
131
+ // ── the cursor ────────────────────────────────────────────────────────────────
132
+ // Hidden for as long as anything of ours is moving — the reveal, the spinner,
133
+ // the pulse — and put back on every way out of this process: the ordinary exit,
134
+ // all three signals, and an uncaught throw, which reaches 'exit' after Node has
135
+ // printed it. Half of this is worse than none: a deck that dies with the cursor
136
+ // hidden leaves the user's shell with no cursor and nothing to do about it but
137
+ // `reset`.
138
+ let cursorHidden = false;
139
+ const showCursor = () => {
140
+ if (!cursorHidden) return;
141
+ cursorHidden = false;
142
+ try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
143
+ };
144
+ if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
145
+ process.on("exit", showCursor);
146
+ // SIGHUP is the one signal this process does not otherwise handle, so its
147
+ // default action would end us before 'exit' could run. Handled only to put the
148
+ // cursor back and then die of it exactly as before — the supervisor reads the
149
+ // signal, not an exit code.
150
+ process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
151
+
152
+ // ── rows ──────────────────────────────────────────────────────────────────────
153
+ // The status column is computed from the longest label. It used to be counted
154
+ // into each string as trailing spaces, so any new row, or any label a character
155
+ // longer, silently broke the alignment of every other one.
156
+ const LABELS = [
157
+ "workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
158
+ "ccusage", "update", "server ready", "log",
159
+ ];
160
+ const LABEL_W = labelColumn(LABELS);
161
+
162
+ function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
163
+ return statusLine({
164
+ mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
165
+ paint: {
166
+ mark: (s) => `${tone}${s}${P.reset}`,
167
+ detail: (s) => `${detailTone}${s}${P.reset}`,
168
+ },
169
+ }) + "\n";
170
+ }
120
171
 
121
- // ── Animated banner ───────────────────────────────────────────────────────────
172
+ // ── the wordmark ──────────────────────────────────────────────────────────────
122
173
  async function printBanner() {
123
- // figlet slant font hardcoded, no runtime dep
124
- const ART = [
125
- ' __ __ __ ',
126
- ' ____ _____ ____ ____ / /______ ____/ /__ _____/ /__',
127
- ' / __ `/ __ `/ _ \\/ __ \\/ __/ ___/_____/ __ / _ \\/ ___/ //_/',
128
- '/ /_/ / /_/ / __/ / / / /_(__ )_____/ /_/ / __/ /__/ ,< ',
129
- '\\__,_/\\__, /\\___/_/ /_/\\__/____/ \\__,_/\\___/\\___/_/|_| ',
130
- ' /____/ ',
131
- ];
132
- const COLORS = [C.dim, C.blue, C.cyan, C.bCyan, C.magenta, C.dim];
133
-
134
- process.stdout.write('\n');
135
-
136
- if (tty) {
137
- const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
138
- for (let i = 0; i < 8; i++) {
139
- process.stdout.write(`\r ${C.bCyan}${frames[i % frames.length]}${C.reset} ${C.dim}loading…${C.reset}`);
140
- await sleep(70);
141
- }
142
- process.stdout.write('\r' + ' '.repeat(28) + '\n');
143
- await sleep(40);
174
+ const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
175
+ for (const line of lines) {
176
+ write(line + "\n");
177
+ // A reveal, not a wait. The once-per-session work is already running under
178
+ // it (see startupWork), so the art costs the boot nothing and the deck is
179
+ // ready about when the last row lands. What used to be here — 560ms of
180
+ // spinner at "loading…" before a single art line — was dead time in a tool
181
+ // whose documented entry point is `npx ccdeck`.
182
+ if (MOTION && line) await sleep(45);
144
183
  }
145
-
146
- for (let i = 0; i < ART.length; i++) {
147
- process.stdout.write(` ${COLORS[i]}${ART[i]}${C.reset}\n`);
148
- if (tty) await sleep(38);
149
- }
150
-
151
- process.stdout.write(`\n ${C.dim}v${PKG_VERSION} · live agent DAG · Claude Code + Codex${C.reset}\n\n`);
152
184
  }
153
185
 
154
- // ── Spinner ───────────────────────────────────────────────────────────────────
155
- function spinner(label) {
156
- if (!tty) { process.stdout.write(` … ${label}\n`); return { stop: (ok, msg) => process.stdout.write(` ${ok ? "✓" : "✗"} ${msg}\n`) }; }
157
- const frames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
186
+ // ── a step, with a spinner only if it is slow enough to need one ──────────────
187
+ // The interval's first frame is 80ms away, so anything already settled when we
188
+ // get here paints nothing at all and the row below is the only trace of it.
189
+ async function step(label, work) {
190
+ if (!MOTION) return work;
191
+ const frames = spinnerFrames(UNICODE);
192
+ // Kept inside the terminal: a label that wraps is a label the \r below can
193
+ // only half erase, and what is left of it stays under the row that follows.
194
+ const text = fit(label, cols() - 6, G.ellipsis);
158
195
  let i = 0;
159
196
  const iv = setInterval(() => {
160
- process.stdout.write(`\r ${C.cyan}${frames[i++ % frames.length]}${C.reset} ${label}`);
197
+ write(`\r ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${P.reset}`);
161
198
  }, 80);
162
- return {
163
- stop(ok, msg) {
164
- clearInterval(iv);
165
- const icon = ok ? `${C.green}✓${C.reset}` : `${C.yellow}✗${C.reset}`;
166
- process.stdout.write(`\r ${icon} ${msg}\n`);
167
- }
168
- };
199
+ try {
200
+ return await work;
201
+ } finally {
202
+ clearInterval(iv);
203
+ // Cleared rather than overwritten: the row that follows is a different
204
+ // length, and relying on it to be the longer of the two is how a spinner
205
+ // leaves its own tail on screen. Nothing to clear if it never painted.
206
+ if (i) write("\r" + " ".repeat(text.length + 5) + "\r");
207
+ }
169
208
  }
170
209
 
171
- let sp;
172
-
173
- // Everything in here is once-per-session setup — hook install, tool probes,
174
- // registry lookups, and about 600ms of deliberate banner animation. A respawn
175
- // is the same session continuing, so it skips the lot and prints one line
176
- // instead. This is the difference between a restart that feels instant and one
177
- // that makes you wonder whether it worked.
178
- if (!RESPAWN) {
179
- await printBanner();
180
-
181
- // ── Startup steps ─────────────────────────────────────────────────────────────
182
- process.stdout.write(` ${C.dim}workspace :${C.reset} ${workspace === "" ? C.yellow + "(all)" + C.reset : workspace}\n`);
183
-
184
- sp = spinner("installing Claude hooks…");
185
- let claudeInstall;
186
- try {
187
- claudeInstall = await installHooks({ provider: "claude" });
188
- } catch (err) {
210
+ /**
211
+ * The once-per-session work, all of it started at once and none of it awaited.
212
+ *
213
+ * Hook install, the claude-swap probe and the registry lookup have nothing to
214
+ * do with each other and nothing to do with the wordmark, so they run underneath
215
+ * the reveal instead of queueing behind it the animation then costs the boot
216
+ * nothing and the deck is ready about when the last art row lands. Every one of
217
+ * them is given its rejection handler here, at the moment it is created, since a
218
+ * promise that settles before anything awaits it is otherwise an unhandled
219
+ * rejection.
220
+ */
221
+ function startupWork() {
189
222
  // Settings the installer cannot parse are settings it cannot rewrite without
190
- // losing them, so it refuses. That refusal has to be said out loud: the file
191
- // it names is one only the user can repair, and every Claude Code session on
192
- // this machine is reading it too.
193
- sp.stop(false, `Claude hooks ${C.dim}not installed${C.reset}`);
194
- console.error(`\n agents-deck: ${err.message}\n`);
195
- process.exit(1);
196
- }
197
- sp.stop(true, `Claude hooks ${C.dim}→ ${claudeInstall.hookPath}${C.reset}`);
198
-
199
- // Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
200
- // command). Instead the server tails Codex's rollout JSONL files directly, so
201
- // there's nothing to install and no /hooks trust step. We just confirm Codex
202
- // is present and let the watcher pick up sessions.
203
- if (wantCodex) {
204
- process.stdout.write(` ${C.green}✓${C.reset} Codex sessions ${C.dim}→ watching ${join(homedir(), ".codex", "sessions")}${C.reset}\n`);
205
- } else {
206
- process.stdout.write(` ${C.dim}Codex watch skipped (no ~/.codex/, or --no-codex)${C.reset}\n`);
223
+ // losing them, so it refuses and that refusal is reported rather than
224
+ // thrown, because it is the only thing the user can act on.
225
+ const hooks = installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }));
226
+
227
+ // claude-swap backs the multi-account panel, and an empty store leaves that
228
+ // panel useless even when the tool is there — so the account already signed
229
+ // in is registered once. Bounded inside seedFirstAccount: empty store only,
230
+ // once ever, never with NO_INSTALL set.
231
+ const cswap = (async () => {
232
+ const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
233
+ const cs = await ensureCswap();
234
+ const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
235
+ if (!usable) return { cs, seed: null };
236
+ const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
237
+ return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
238
+ })().catch(() => null);
239
+
240
+ // ccusage backs the usage-history modal. Primed at boot rather than on first
241
+ // open so a cold machine pays the install while the deck is still starting.
242
+ const ccusage = (async () => {
243
+ if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
244
+ const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
245
+ return primeCcusage();
246
+ })().catch(() => null);
247
+
248
+ // A newer release on npm, said once, in the place the upgrade gets typed.
249
+ // Hard-capped so a slow registry cannot delay the server — the answer is
250
+ // usually already cached in ~/.agents-deck/.self-update-check anyway. It has
251
+ // to resolve BEFORE the pulse indicator starts writing over the last line.
252
+ const update = Promise.race([
253
+ import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
254
+ .then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
255
+ .then(r => (r?.notice?.kind === "upgrade" ? r : null))
256
+ .catch(() => null),
257
+ new Promise(r => setTimeout(() => r(null), 1200)),
258
+ ]);
259
+
260
+ return { hooks, cswap, ccusage, update };
207
261
  }
208
262
 
209
- // claude-swap backs the multi-account panel. Installing it touches the user's
210
- // global tool path, so unlike the ccusage install this one announces itself.
211
- {
212
- const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
213
- const csp = spinner("checking claude-swap…");
214
- const cs = await ensureCswap();
215
- if (cs.state === "present") {
216
- csp.stop(true, `claude-swap ${C.dim}→ v${cs.version} (accounts panel enabled)${C.reset}`);
217
- } else if (cs.state === "installed") {
218
- csp.stop(true, `claude-swap ${C.dim}→ installed v${cs.version} via ${cs.via}${C.reset}`);
219
- } else if (cs.state === "upgrading") {
220
- csp.stop(true, `claude-swap ${C.dim}→ v${cs.version}, upgrading to v${cs.latest} in background${C.reset}`);
221
- } else if (cs.state === "skipped") {
222
- csp.stop(true, `claude-swap ${C.dim}not installed (AGENTS_DECK_NO_INSTALL=1)${C.reset}`);
263
+ /** The same work, said out loud, in a fixed order — a boot whose rows arrive in
264
+ * whatever order the network settled is a boot nobody can scan twice. */
265
+ async function reportStartup(jobs) {
266
+ write(row({
267
+ mark: G.ok, label: "workspace",
268
+ detail: workspace === "" ? "(all)" : workspace,
269
+ detailTone: workspace === "" ? P.warn : P.muted,
270
+ }));
271
+
272
+ const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
273
+ if (!hooks.ok) {
274
+ // The file it names is one only the user can repair, and every Claude Code
275
+ // session on this machine is reading it too.
276
+ write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
277
+ console.error(`\n agents-deck: ${hooks.err.message}\n`);
278
+ process.exit(1);
279
+ }
280
+ write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
281
+
282
+ // Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
283
+ // command). Instead the server tails Codex's rollout JSONL files directly, so
284
+ // there's nothing to install and no /hooks trust step. We just confirm Codex
285
+ // is present and let the watcher pick up sessions.
286
+ if (wantCodex) {
287
+ const dir = join(homedir(), ".codex", "sessions");
288
+ write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(dir)}` }));
289
+ } else {
290
+ write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
291
+ }
292
+
293
+ const swap = await step(`checking claude-swap${G.ellipsis}`, jobs.cswap);
294
+ const cs = swap?.cs;
295
+ if (cs?.state === "present") {
296
+ write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` }));
297
+ } else if (cs?.state === "installed") {
298
+ write(row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` }));
299
+ } else if (cs?.state === "upgrading") {
300
+ write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` }));
301
+ } else if (cs?.state === "skipped") {
302
+ write(row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" }));
223
303
  } else {
224
- const how = cs.reason === "no_installer"
225
- ? "not installed the accounts panel needs it"
226
- : cs.reason === "not_on_path"
227
- ? `installed via ${cs.via} but not on PATH add ${
304
+ const how = cs?.reason === "no_installer"
305
+ ? `not installed ${G.dash} the accounts panel needs it`
306
+ : cs?.reason === "not_on_path"
307
+ ? `installed via ${cs.via} but not on PATH ${G.dash} add ${
228
308
  process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
229
309
  }`
230
- : `install failed via ${cs.via}`;
231
- csp.stop(false, `claude-swap ${C.dim}${how}${C.reset}`);
310
+ : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
311
+ write(row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how }));
232
312
  // A URL is not an answer when someone just wants the panel to work. Print
233
313
  // the command for THIS machine, picked from what is already on it.
234
- if (cs.hint) process.stdout.write(` ${C.dim}${cs.hint}${C.reset}\n`);
314
+ if (cs?.hint) write(row({ label: "", detail: cs.hint }));
235
315
  }
236
316
 
237
- // A working claude-swap with an empty store still leaves the panel useless,
238
- // so the account already signed in is registered once. Bounded inside
239
- // seedFirstAccount: empty store only, once ever, never with NO_INSTALL set.
240
- if (cs.state === "present" || cs.state === "installed" || cs.state === "upgrading") {
241
- const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
242
- const seed = await seedFirstAccount().catch(() => ({ state: "failed" }));
243
- if (seed.state === "added") {
244
- process.stdout.write(` ${C.green}✓${C.reset} accounts ${C.dim}registered the signed-in account (cswap add)${C.reset}\n`);
245
- } else if (seed.state === "failed" || seed.state === "nothing-to-add") {
246
- process.stdout.write(` ${C.dim} accounts panel empty — sign in to Claude Code, then run cswap add${C.reset}\n`);
247
- }
317
+ if (swap?.seed?.state === "added") {
318
+ write(row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" }));
319
+ } else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
320
+ write(row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` }));
248
321
  }
249
- }
250
322
 
251
- // ccusage backs the usage-history modal. Primed here rather than on first
252
- // open so a cold machine pays the install while the deck is still booting.
253
- if (process.env.AGENTS_DECK_NO_INSTALL !== "1") {
254
- const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
255
- const cu = primeCcusage();
256
- if (cu.state === "present") process.stdout.write(` ${C.green}✓${C.reset} ccusage ${C.dim}→ v${cu.version}${C.reset}\n`);
257
- else if (cu.state === "updating") process.stdout.write(` ${C.green}✓${C.reset} ccusage ${C.dim}→ v${cu.version}, checking for update${C.reset}\n`);
258
- else if (cu.state === "installing") process.stdout.write(` ${C.green}✓${C.reset} ccusage ${C.dim}installing in background${C.reset}\n`);
323
+ const cu = await jobs.ccusage;
324
+ if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
325
+ else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
326
+ else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
327
+
328
+ const upgrade = await jobs.update;
329
+ if (upgrade) {
330
+ write(row({
331
+ mark: G.up, tone: P.warn, label: "update",
332
+ detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
333
+ }));
334
+ }
259
335
  }
260
336
 
261
- // A newer release on npm, said once, in the place the upgrade gets typed.
262
- // Started here and collected below so the lookup overlaps the rest of boot, and
263
- // hard-capped so a slow registry cannot delay the server the answer is
264
- // usually already cached in ~/.agents-deck/.self-update-check anyway. It has to
265
- // resolve BEFORE the pulse indicator starts writing over the last line.
266
- const selfCheck = import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
267
- .then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
268
- .catch(() => null);
269
- const upgrade = await Promise.race([
270
- selfCheck.then(r => r?.notice?.kind === "upgrade" ? r : null),
271
- new Promise(r => setTimeout(() => r(null), 1200)),
272
- ]);
273
- if (upgrade) {
274
- process.stdout.write(
275
- ` ${C.yellow}↑${C.reset} update ${C.dim}v${upgrade.notice.to} available — ${C.reset}${C.yellow}${upgrade.command}${C.reset}\n`,
276
- );
337
+ // Everything above is once-per-session setup hook install, tool probes,
338
+ // registry lookups, and the banner it now runs underneath. A respawn is the
339
+ // same session continuing, so it skips the lot and prints one line instead.
340
+ // This is the difference between a restart that feels instant and one that
341
+ // makes you wonder whether it worked.
342
+ if (!RESPAWN) {
343
+ const jobs = startupWork();
344
+ await printBanner();
345
+ await reportStartup(jobs);
277
346
  }
278
- } // end !RESPAWN
279
347
 
280
348
  // Asking the supervisor to bring us back. It is the only party that can, and
281
349
  // only after this process is gone — which is precisely what keeps the
@@ -310,7 +378,7 @@ const requestRestart = (mode) => {
310
378
  return;
311
379
  }
312
380
  const to = restartTarget();
313
- process.stdout.write(`\n ${C.yellow}↻${C.reset} ${C.dim}restarting${to ? ` v${to}` : ""}…${C.reset}\n`);
381
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
314
382
  shutdown(RESTART_CODE);
315
383
  };
316
384
 
@@ -322,9 +390,9 @@ const abandonUpgrade = (why) => {
322
390
  restarting = false;
323
391
  // The server's own latch, which no longer has an exiting process to clear it.
324
392
  releaseRestart();
325
- process.stdout.write(
326
- `\n ${C.yellow}✕${C.reset} ${C.dim}update not applied still on ${C.reset}v${PKG_VERSION}\n` +
327
- (why ? ` ${C.dim}${why}${C.reset}\n` : ""),
393
+ write(
394
+ `\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
395
+ (why ? ` ${P.muted}${why}${P.reset}\n` : ""),
328
396
  );
329
397
  };
330
398
 
@@ -337,7 +405,7 @@ process.on("message", (m) => {
337
405
  clearTimeout(upgradeTimer);
338
406
  // The replacement is on the machine now, so this is the last moment the
339
407
  // port is worth holding: exiting hands it straight over.
340
- process.stdout.write(`\n ${C.yellow}↻${C.reset} ${C.dim}updating via npx…${C.reset}\n`);
408
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
341
409
  shutdown(UPGRADE_CODE);
342
410
  } else if (m.type === "upgrade-refused") {
343
411
  abandonUpgrade(m.error);
@@ -351,15 +419,16 @@ function restartTarget() {
351
419
  catch { return null; }
352
420
  }
353
421
 
354
- if (!RESPAWN) sp = spinner("starting server…");
355
- const server = await startServer({
422
+ const starting = startServer({
356
423
  port, persist, workspace, codex: wantCodex,
357
424
  // Withheld when nothing is supervising us: without a parent, exiting is just
358
425
  // exiting, and /api/restart answers 501 so the UI hides the control.
359
426
  onRestart: SUPERVISED ? requestRestart : null,
360
- }).catch(err => {
361
- if (sp) sp.stop(false, `server failed: ${err.message}`);
362
- else console.error(`agents-deck: server failed: ${err.message}`);
427
+ });
428
+ const server = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting)).catch(err => {
429
+ // stderr, not a row: a deck that could not bind is not a status line, and
430
+ // whatever launched it reads this stream.
431
+ console.error(`agents-deck: server failed: ${err.message}`);
363
432
  process.exit(1);
364
433
  });
365
434
  const addr = server.address();
@@ -373,15 +442,21 @@ const url = `http://127.0.0.1:${realPort}`;
373
442
  try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
374
443
 
375
444
  if (RESPAWN) {
376
- process.stdout.write(` ${C.green}↻${C.reset} ${C.dim}restarted ${C.reset}v${PKG_VERSION}${C.dim} · ${url}${C.reset}\n`);
445
+ write(` ${P.ok}${G.restart}${P.reset} ${P.muted}restarted ${G.arrow} ${P.reset}v${PKG_VERSION}${P.muted} ${G.bullet} ${link(url, url, LINKS)}${P.reset}\n`);
377
446
  } else {
378
- sp.stop(true, `server ready ${C.dim}→ ${C.reset}${C.bCyan}${C.bold}${url}${C.reset}`);
379
- if (persist) process.stdout.write(` ${C.dim}log : ${persist}${C.reset}\n`);
447
+ // The URL is the one detail an ellipsis would destroy — half an address is
448
+ // not a shorter address — so it keeps its own line when the terminal is too
449
+ // narrow to hold it beside the label. See statusLine's `keep`.
450
+ write(row({
451
+ mark: G.ok, label: "server ready",
452
+ detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
453
+ }));
454
+ if (persist) write(row({ label: "log", detail: fileLink(persist) }));
380
455
  // Only when one is actually being opened. Under --no-open — which is how an
381
456
  // npx update relaunches, with a tab already waiting — this was announcing
382
457
  // something that never happened.
383
- if (openBrowser) process.stdout.write(`\n ${C.green}${C.bold} opening browser…${C.reset}\n\n`);
384
- else process.stdout.write("\n");
458
+ if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
459
+ else write("\n");
385
460
  }
386
461
 
387
462
  // The discovery file is the whole of how a hook finds this deck: hook.js
@@ -428,21 +503,19 @@ if (openBrowser && !RESPAWN) {
428
503
  }
429
504
 
430
505
  // ── Pulse indicator ───────────────────────────────────────────────────────────
431
- if (tty) {
432
- const pulseFrames = [`${C.green}●${C.reset}`, `${C.dim}●${C.reset}`];
433
- const LISTENING = "listeningCtrl+C to stop";
434
- // A deck no hook can find is not listening in any sense the user cares
435
- // about, and the pulse is the one line that stays on screen for hours — so
436
- // it is where this has to be said.
437
- const UNREGISTERED = "listening, but not registered — hooks cannot find this deck";
438
- // Padded on the plain text, before any colour: the line is redrawn over
439
- // itself with \r, so the shorter message has to cover the longer one.
440
- const width = UNREGISTERED.length + 3;
506
+ // The whole line is rewritten each beat rather than just the dot: anything else
507
+ // on this deck that has something to say writes a newline first, and after that
508
+ // the line under the cursor is no longer the one we drew a partial repaint
509
+ // would leave the message behind and pulse into empty space. Sized to the real
510
+ // terminal, because at 40 columns the old fixed 61-character line wrapped, and
511
+ // from then on \r only ever reached its second row.
512
+ if (MOTION) {
441
513
  let pi = 0;
442
514
  setInterval(() => {
443
- const text = (registered ? LISTENING : UNREGISTERED).padEnd(width);
444
- const colour = registered ? C.dim : C.yellow;
445
- process.stdout.write(`\r ${pulseFrames[pi++ % 2]} ${colour}${text}${C.reset}`);
515
+ const text = pulseText({ registered, columns: cols(), unicode: UNICODE });
516
+ const dot = pi++ % 2 === 0 ? (registered ? P.ok : P.warn) : P.muted;
517
+ const tone = registered ? P.muted : P.warn;
518
+ write(`\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`);
446
519
  }, 800).unref();
447
520
  }
448
521
 
@@ -451,8 +524,11 @@ const shutdown = async (code = 0) => {
451
524
  // on its own before either timer runs, Node would otherwise exit 0 and the
452
525
  // supervisor would take that as "done" instead of "bring me back".
453
526
  process.exitCode = code;
527
+ // Before anything that can take time: a Ctrl+C the user has to watch for a
528
+ // second and a half is a second and a half without a cursor.
529
+ showCursor();
454
530
  if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
455
- process.stdout.write(`\n\n ${C.yellow} shutting down…${C.reset}\n`);
531
+ write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
456
532
  }
457
533
  // Stopped first, always: a tick landing after the unlink would re-register a
458
534
  // deck that is on its way out, and leave the file behind for the hooks to
@@ -477,15 +553,15 @@ process.on("beforeExit", () => { discovery.stop(); removeDiscovery(discoveryFile
477
553
  // reason — because the alternative is what this replaced: an ordinary-looking
478
554
  // deck that simply never shows a session.
479
555
  function reportUnregistered({ file, error }) {
480
- const why = error?.message ? ` ${error.message}` : "";
481
- process.stdout.write(
482
- `\n ${C.yellow}⚠${C.reset} ${C.bold}not registered${C.reset}${C.dim}${why}${C.reset}\n` +
483
- ` ${C.dim}hooks find this deck through ${file}, so until that file exists no events arrive.${C.reset}\n`,
556
+ const why = error?.message ? ` ${G.dash} ${error.message}` : "";
557
+ write(
558
+ `\n ${P.warn}${G.warn}${P.reset} ${P.bold}not registered${P.reset}${P.muted}${why}${P.reset}\n` +
559
+ ` ${P.muted}hooks find this deck through ${file}, so until that file exists no events arrive.${P.reset}\n`,
484
560
  );
485
561
  }
486
562
 
487
563
  function reportReregistered({ file }) {
488
- process.stdout.write(`\n ${C.green}✓${C.reset} ${C.dim}registered again ${file}${C.reset}\n`);
564
+ write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
489
565
  }
490
566
 
491
567
  function parseArgs(args) {