agent-dag 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,39 @@ What the deck does write, and the short list of what does leave the machine, is
68
68
  - Node.js ≥ 18 — macOS, Linux and Windows
69
69
  - Claude Code CLI or OpenAI Codex CLI (or both)
70
70
  - Optional: [claude-swap](https://pypi.org/project/claude-swap/) for the Accounts panel; the deck can install it for you
71
+ - Nothing else. On Apple Silicon the deck fetches [`macmon`](https://github.com/vladkens/macmon) itself for the temperature rows; see below.
72
+
73
+ ### Temperature, per machine
74
+
75
+ The machine panel shows a **Thermal** section only where the machine actually answers, and it never invents a reading — no sensor means no row.
76
+
77
+ | | reads | needs |
78
+ | --- | --- | --- |
79
+ | Linux | `/sys/class/hwmon` | nothing |
80
+ | Windows | the `Thermal Zone Information` performance counter | nothing — where the machine has an ACPI thermal zone. Many do not; see below |
81
+ | macOS, Intel | `ioreg` for the GPU, `pmset -g therm` for throttling | nothing |
82
+ | macOS, Apple Silicon | `macmon`, which the deck fetches for you | nothing |
83
+
84
+ Apple Silicon is the one that needs a tool, and it is not an oversight. No command that ships with macOS prints a temperature on an M-series Mac: `powermetrics` needs root, `pmset -g therm` records nothing there, and the sensors sit behind a private API that only native code can call. [`macmon`](https://github.com/vladkens/macmon) reads them without `sudo` and covers M1 through M5.
85
+
86
+ You do not have to install it. The deck downloads the published binary into `~/.agents-deck/tools/macmon` — the same place it already keeps `uv` — verifies it against the SHA-256 the GitHub release publishes, checks that it runs, and only then uses it. Not through Homebrew, because a machine without Homebrew would need Homebrew installed first, and that is a large thing to do to somebody who asked for a dashboard. It happens in the background, after the deck is already up, and never on the first run's critical path.
87
+
88
+ It is skipped entirely on a machine that already answers — an Intel Mac never downloads anything — and on one where you have `macmon` yourself, which is found on either Homebrew prefix. `AGENTS_DECK_NO_DOWNLOAD=1` turns it off on its own; `AGENTS_DECK_NO_INSTALL=1` turns it off along with everything else.
89
+
90
+ #### Windows, and why it is often blank
91
+
92
+ Windows is the platform where this most often shows nothing, and that is not a defect in the deck. Measured on a physical Windows 11 laptop — a Lenovo IdeaPad L340, Intel i5-9300H, English install, checked both as an ordinary user and as an administrator:
93
+
94
+ - its firmware declares **zero** ACPI thermal zones, so the performance counter above is registered but has no instances
95
+ - `MSAcpi_ThermalZoneTemperature` answers `Not supported` **even to an administrator**
96
+ - `Win32_TemperatureProbe` exists but every field reads `32768`, which is WMI's value for "unknown"
97
+ - the sensors are real and actively managed — Intel Dynamic Tuning is running — but it publishes them in `root\WMI EsifDeviceInformation`, which is **Access denied** without administrator
98
+
99
+ That is a class of machine, not a fault: modern Intel laptops moved thermal management into Intel DTT and stopped declaring the ACPI zones that Windows exposes to ordinary programs. There is no standard user-mode Windows API for CPU temperature — which is why HWiNFO, Core Temp and LibreHardwareMonitor all install a kernel driver, and why this deck does not.
100
+
101
+ Where the counter does have instances — many desktop boards, servers, and older laptops — it is read without any privileges at all. Its path is currently English-only; see [#747](https://github.com/BarganConstantin/ccdeck/issues/747).
102
+
103
+ One thing does work on the machines above, and it costs you nothing to have: if **LibreHardwareMonitor** happens to be running with its web server on, the deck reads its numbers over plain HTTP on localhost, which needs no privileges. That is a read, not a request — the deck does not install it, will not ask you to, and shows no section if it is not there. It is mentioned only so nobody is surprised to see degrees appear on a machine that had none.
71
104
 
72
105
  ## How it works
73
106
 
package/bin/deck.js CHANGED
@@ -11,11 +11,12 @@ import { dieOfSignal, dieWithParent } from "../src/server/supervisor.mjs";
11
11
  import { isPortValue, parseArgs } from "../src/server/args.mjs";
12
12
  import {
13
13
  CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
14
- palette, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK,
15
- unregisteredDetail, wordmark,
14
+ palette, pulseDot, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns,
15
+ elapsedSuffix, unicodeOK, unregisteredDetail, visibleWidth, wordmark,
16
16
  } from "../src/server/term.mjs";
17
17
  import { PRODUCT } from "../src/server/brand.mjs";
18
18
  import { invokedName, renameNotice } from "../src/server/invoked-as.mjs";
19
+ import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
19
20
 
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
22
  const PKG_ROOT = resolve(__dirname, "..");
@@ -245,6 +246,20 @@ const cols = () => termColumns(process.stdout);
245
246
  const sleep = ms => new Promise(r => setTimeout(r, ms));
246
247
  const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
247
248
 
249
+ /**
250
+ * What is still happening after the report ended, in three words or so.
251
+ *
252
+ * #742 left one job able to outlive the boot — an install of claude-swap that
253
+ * the report stopped waiting for — and the terminal said nothing about it once
254
+ * the rows were done. That is the wrong way round: the row that had finished
255
+ * was the one blinking, and the thing that really was working sat still. Now
256
+ * the pulse line carries the label while the job runs and drops it when the job
257
+ * settles, which is also what decides whether the line moves at all. Declared
258
+ * here rather than beside `registered` below, because reportStartup sets it and
259
+ * runs long before that line does.
260
+ */
261
+ let pulseBusy = null;
262
+
248
263
  // ── the cursor ────────────────────────────────────────────────────────────────
249
264
  // Hidden for as long as anything of ours is moving — the reveal, the spinner,
250
265
  // the pulse — and put back on every way out of this process: the ordinary exit,
@@ -288,31 +303,72 @@ function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.
288
303
  }
289
304
 
290
305
  // ── the wordmark ──────────────────────────────────────────────────────────────
306
+
307
+ /**
308
+ * Hold anything else that wants to speak until the art is finished.
309
+ *
310
+ * #742, found while watching a first run through a pty: the startup jobs run
311
+ * UNDER the reveal on purpose, and one of them failing writes to console.error
312
+ * the moment it fails — which put
313
+ *
314
+ * ccdeck ccusage: install failed: npm install ccusage failed: spawn npm ENOENT
315
+ *
316
+ * between the second and third rows of the logo. A wordmark with a stack of
317
+ * someone else's bad news through the middle of it is the first thing a new
318
+ * user sees, and it reads as a crash rather than as a note.
319
+ *
320
+ * The window is the reveal and nothing else — about 180ms — so at worst a
321
+ * message arrives a fifth of a second later than it would have, on the one
322
+ * stretch of the boot where there is nowhere for it to go. Restored in a
323
+ * `finally`, so a throw inside the reveal cannot leave the process mute.
324
+ */
325
+ function holdConsole() {
326
+ const held = [];
327
+ const real = { warn: console.warn, error: console.error, log: console.log };
328
+ for (const k of Object.keys(real)) console[k] = (...args) => { held.push([k, args]); };
329
+ return () => {
330
+ Object.assign(console, real);
331
+ for (const [k, args] of held) real[k](...args);
332
+ };
333
+ }
334
+
291
335
  async function printBanner() {
292
336
  const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
293
- for (const line of lines) {
294
- write(line + "\n");
295
- // A reveal, not a wait. The once-per-session work is already running under
296
- // it (see startupWork), so the art costs the boot nothing and the deck is
297
- // ready about when the last row lands. What used to be here 560ms of
298
- // spinner at "loading…" before a single art line was dead time in a tool
299
- // whose documented entry point is `npx ccdeck`.
300
- if (MOTION && line) await sleep(45);
337
+ const release = holdConsole();
338
+ try {
339
+ for (const line of lines) {
340
+ write(line + "\n");
341
+ // A reveal, not a wait. The once-per-session work is already running under
342
+ // it (see startupWork), so the art costs the boot nothing and the deck is
343
+ // ready about when the last row lands. What used to be here — 560ms of
344
+ // spinner at "loading…" before a single art line was dead time in a tool
345
+ // whose documented entry point is `npx ccdeck`.
346
+ if (MOTION && line) await sleep(45);
347
+ }
348
+ } finally {
349
+ release();
301
350
  }
302
351
  }
303
352
 
304
353
  // ── a step, with a spinner only if it is slow enough to need one ──────────────
305
354
  // The interval's first frame is 80ms away, so anything already settled when we
306
355
  // get here paints nothing at all and the row below is the only trace of it.
356
+
307
357
  async function step(label, work) {
308
358
  if (!MOTION) return work;
309
359
  const frames = spinnerFrames(UNICODE);
310
360
  // Kept inside the terminal: a label that wraps is a label the \r below can
311
361
  // only half erase, and what is left of it stays under the row that follows.
312
- const text = fit(label, cols() - 6, G.ellipsis);
362
+ // Six columns for the indent and the spinner, four more so the elapsed
363
+ // seconds have somewhere to go without pushing the label off the edge.
364
+ const text = fit(label, cols() - 10, G.ellipsis);
365
+ const started = Date.now();
313
366
  let i = 0;
367
+ let widest = 0;
314
368
  const iv = setInterval(() => {
315
- write(`\r ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${P.reset}`);
369
+ const line = ` ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${elapsedSuffix(Date.now() - started)}${P.reset}`;
370
+ widest = Math.max(widest, visibleWidth(line));
371
+ write(`\r${line}`);
316
372
  }, 80);
317
373
  try {
318
374
  return await work;
@@ -320,8 +376,10 @@ async function step(label, work) {
320
376
  clearInterval(iv);
321
377
  // Cleared rather than overwritten: the row that follows is a different
322
378
  // length, and relying on it to be the longer of the two is how a spinner
323
- // leaves its own tail on screen. Nothing to clear if it never painted.
324
- if (i) write("\r" + " ".repeat(text.length + 5) + "\r");
379
+ // leaves its own tail on screen. Measured rather than computed, because the
380
+ // line grows when the elapsed seconds appear and again when they reach two
381
+ // digits. Nothing to clear if it never painted.
382
+ if (i) write("\r" + " ".repeat(widest) + "\r");
325
383
  }
326
384
  }
327
385
 
@@ -357,10 +415,20 @@ function startupWork() {
357
415
  // panel useless even when the tool is there — so the account already signed
358
416
  // in is registered once. Bounded inside seedFirstAccount: empty store only,
359
417
  // once ever, never with NO_INSTALL set.
418
+ //
419
+ // `cswapInstalling` is the other half, and it is what keeps a first run from
420
+ // spending the boot's whole deadline on a question that has already been
421
+ // answered: ensureCswap resolves it the moment it commits to an install, so
422
+ // the report can stop waiting then rather than eight seconds later. It never
423
+ // settles on the machines where there is nothing to install, which is every
424
+ // machine after the first run.
425
+ let sayInstalling;
426
+ const cswapInstalling = new Promise(r => { sayInstalling = r; });
427
+
360
428
  const cswap = (async () => {
361
429
  if (!wantClaude) return null;
362
430
  const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
363
- const cs = await ensureCswap();
431
+ const cs = await ensureCswap({ onInstalling: () => sayInstalling() });
364
432
  const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
365
433
  if (!usable) return { cs, seed: null };
366
434
  const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
@@ -390,12 +458,17 @@ function startupWork() {
390
458
  new Promise(r => setTimeout(() => r(null), 1200)),
391
459
  ]);
392
460
 
393
- return { hooks, cswap, ccusage, update };
461
+ return { hooks, cswap, cswapInstalling, ccusage, update };
394
462
  }
395
463
 
396
464
  /** The same work, said out loud, in a fixed order — a boot whose rows arrive in
397
465
  * whatever order the network settled is a boot nobody can scan twice. */
398
466
  async function reportStartup(jobs) {
467
+ // What the whole report may spend waiting, shared by every job under it
468
+ // rather than granted to each — four jobs at eight seconds each is a
469
+ // thirty-two second boot that no individual deadline would object to.
470
+ const left = budget(bootDeadlineMs());
471
+
399
472
  write(row({
400
473
  mark: G.ok, label: "workspace",
401
474
  detail: workspace === "" ? "(all)" : workspace,
@@ -435,22 +508,96 @@ async function reportStartup(jobs) {
435
508
  write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
436
509
  }
437
510
 
438
- const swap = await step(`checking claude-swap${G.ellipsis}`, jobs.cswap);
511
+ // Bounded, because this is the job that made a first boot look hung: on a
512
+ // machine with neither claude-swap nor a Python toolchain it fetches a uv
513
+ // binary and then builds an environment with it, and the report used to wait
514
+ // out both. See src/server/boot-deadline.mjs. Nothing is cancelled — the
515
+ // install carries on and says how it went when it knows.
516
+ const swapWait = await step(`checking claude-swap${G.ellipsis}`, Promise.race([
517
+ left.within(jobs.cswap),
518
+ // The install announcing itself. Not a timeout — a decision, arriving in
519
+ // about a second on the boot that would otherwise have paid the full
520
+ // deadline for news it already had.
521
+ jobs.cswapInstalling.then(() => ({ done: false, installing: true })),
522
+ ]));
523
+ if (swapWait.done) writeSwapRows(swapWait.value);
524
+ else {
525
+ write(row({
526
+ label: "claude-swap",
527
+ // Both halves earn their place, and both have to survive an 80-column
528
+ // terminal: what the job is doing, and that waiting for it is not the
529
+ // user's problem. The row that only said the first is the row this
530
+ // replaces — a spinner at "checking claude-swap…" says that much.
531
+ detail: swapWait.installing
532
+ ? `installing in the background ${G.dash} the deck is ready`
533
+ : `still setting up ${G.dash} the deck is ready`,
534
+ }));
535
+ // Handed to the pulse line, which is the only thing still on screen once
536
+ // the rows are done — and taken back the moment the job settles, whichever
537
+ // way it settled.
538
+ pulseBusy = swapWait.installing ? "installing claude-swap" : "setting up claude-swap";
539
+ jobs.cswap.then(
540
+ late => { pulseBusy = null; writeSwapRows(late, { late: true }); },
541
+ () => { pulseBusy = null; },
542
+ );
543
+ }
544
+
545
+ const cu = await left.within(jobs.ccusage);
546
+ writeCcusageRow(cu.done ? cu.value : null);
547
+
548
+ const upgrade = await jobs.update;
549
+ if (upgrade) {
550
+ write(row({
551
+ mark: G.up, tone: P.warn, label: "update",
552
+ detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
553
+ }));
554
+ }
555
+
556
+ // Which name this deck was started under, when that is knowable — a notice,
557
+ // never a refusal. 95% of installs are on the two old names and the update
558
+ // path runs through this very process, so a build that declined to boot under
559
+ // one of them would kill the deck on the machine where the deck is what would
560
+ // have explained why. Nothing at all is printed wherever the typed name
561
+ // cannot be proven (a Windows global install, a git checkout): telling
562
+ // somebody who already types `ccdeck` to type `ccdeck` is the one failure
563
+ // that would make this row worth ignoring. See src/server/invoked-as.mjs.
564
+ const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
565
+ if (rename) {
566
+ write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
567
+ // The line that carries the value: for a global install there is nothing to
568
+ // install and nothing to download, only six different characters to type.
569
+ write(row({ label: "", detail: rename.fix }));
570
+ }
571
+ }
572
+
573
+ /**
574
+ * The claude-swap rows, wherever in the boot they end up being printed.
575
+ *
576
+ * `late` is the one difference, and it is not cosmetic: by the time a late row
577
+ * arrives the pulse indicator owns the last line and repaints it with `\r`, so
578
+ * a row written without a newline first would be drawn over on the next beat.
579
+ * Every other thing that speaks after boot — reportUnregistered,
580
+ * reportReregistered — opens with the same newline for the same reason.
581
+ */
582
+ function writeSwapRows(swap, { late = false } = {}) {
439
583
  const cs = swap?.cs;
584
+ // Collected rather than written one at a time, because a late report opens
585
+ // with a newline and there is exactly one of those however many rows follow.
586
+ let out = "";
440
587
  if (!wantClaude) {
441
588
  // claude-swap is a Python tool that switches Claude Code accounts, and the
442
589
  // deck used to fetch a uv binary to install it on machines with no Claude
443
590
  // Code at all. Saying so is the point of the row: it is the one place a
444
591
  // user can learn that the accounts panel is missing on purpose.
445
- write(row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` }));
592
+ out += row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` });
446
593
  } else if (cs?.state === "present") {
447
- write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` }));
594
+ out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` });
448
595
  } else if (cs?.state === "installed") {
449
- write(row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` }));
596
+ out += row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` });
450
597
  } else if (cs?.state === "upgrading") {
451
- write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` }));
598
+ out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` });
452
599
  } else if (cs?.state === "skipped") {
453
- write(row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" }));
600
+ out += row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" });
454
601
  } else {
455
602
  const how = cs?.reason === "no_installer"
456
603
  ? `not installed ${G.dash} the accounts panel needs it`
@@ -459,19 +606,32 @@ async function reportStartup(jobs) {
459
606
  process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
460
607
  }`
461
608
  : `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
462
- write(row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how }));
609
+ out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
463
610
  // A URL is not an answer when someone just wants the panel to work. Print
464
611
  // the command for THIS machine, picked from what is already on it.
465
- if (cs?.hint) write(row({ label: "", detail: cs.hint }));
612
+ if (cs?.hint) out += row({ label: "", detail: cs.hint });
466
613
  }
467
614
 
468
615
  if (swap?.seed?.state === "added") {
469
- write(row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" }));
616
+ out += row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" });
470
617
  } else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
471
- write(row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` }));
618
+ out += row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` });
472
619
  }
473
620
 
474
- const cu = await jobs.ccusage;
621
+ write(late ? "\n" + out : out);
622
+ }
623
+
624
+ /**
625
+ * The ccusage row.
626
+ *
627
+ * `null` covers both of the ways there is nothing to say — the job was not
628
+ * attempted, and the job had not answered by the time the boot's deadline ran
629
+ * out. Neither deserves a row: unlike claude-swap there is no install to wait
630
+ * for here, because primeCcusage starts one and returns without it, so a
631
+ * ccusage that is slow to answer is slow at resolving a path and will be
632
+ * resolved again the first time the usage modal is opened.
633
+ */
634
+ function writeCcusageRow(cu) {
475
635
  if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
476
636
  else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
477
637
  // A ccusage the user provided, named rather than versioned — reading a
@@ -481,30 +641,6 @@ async function reportStartup(jobs) {
481
641
  // with a managed install AND a PATH copy could not answer before #433.
482
642
  else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
483
643
  else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
484
-
485
- const upgrade = await jobs.update;
486
- if (upgrade) {
487
- write(row({
488
- mark: G.up, tone: P.warn, label: "update",
489
- detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
490
- }));
491
- }
492
-
493
- // Which name this deck was started under, when that is knowable — a notice,
494
- // never a refusal. 95% of installs are on the two old names and the update
495
- // path runs through this very process, so a build that declined to boot under
496
- // one of them would kill the deck on the machine where the deck is what would
497
- // have explained why. Nothing at all is printed wherever the typed name
498
- // cannot be proven (a Windows global install, a git checkout): telling
499
- // somebody who already types `ccdeck` to type `ccdeck` is the one failure
500
- // that would make this row worth ignoring. See src/server/invoked-as.mjs.
501
- const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
502
- if (rename) {
503
- write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
504
- // The line that carries the value: for a global install there is nothing to
505
- // install and nothing to download, only six different characters to type.
506
- write(row({ label: "", detail: rename.fix }));
507
- }
508
644
  }
509
645
 
510
646
  // Asking the supervisor to bring us back. It is the only party that can, and
@@ -533,11 +669,13 @@ let upgradeTimer = null;
533
669
  // So an ask that arrives too early is held rather than run: the user asked for
534
670
  // something this deck can genuinely give a moment later, and refusing outright
535
671
  // would put back the same silence in a politer form. BOOT_RESTART_MS is the
536
- // outer bound, for the reason UPGRADE_ANSWER_MS above is one and since #483
537
- // moved the listen in front of the report, it is a bound that gets used: a boot
538
- // waiting out a real `uv tool install` is minutes long, and the restart is the
539
- // right answer to it rather than a casualty of it. Ten seconds in, the ask is
540
- // run; the respawn skips the report entirely and is up in about a second.
672
+ // outer bound, for the reason UPGRADE_ANSWER_MS above is one. It used to be a
673
+ // bound that got used before #742 the report waited out a real `uv tool
674
+ // install`, so the window it covers was minutes wide. It is now the boot
675
+ // deadline plus the browser spawn, comfortably inside ten seconds, and this
676
+ // stays as the thing that makes that a fact rather than a belief. Ten seconds
677
+ // in, the ask is run; the respawn skips the report entirely and is up in about
678
+ // a second.
541
679
  let booted = false;
542
680
  let heldRestart = null;
543
681
  let bootTimer = null;
@@ -831,9 +969,14 @@ await discovery.check();
831
969
  // Never on a respawn: the tab that asked for the restart is still open and
832
970
  // reconnecting on its own. A second one would be the deck talking over itself.
833
971
  if (openBrowser && !RESPAWN) {
972
+ // Not awaited, and not a dependency any more. `open@10` was this package's
973
+ // only runtime dependency and brought nine more with it, all of them fetched
974
+ // on a cold `npx ccdeck` before the deck's own tarball is unpacked — and the
975
+ // await under it held the boot behind a launcher that has nothing to report.
976
+ // See src/server/open-url.mjs.
834
977
  try {
835
- const { default: open } = await import("open");
836
- await open(url);
978
+ const { openUrl } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
979
+ openUrl(url);
837
980
  } catch {}
838
981
  }
839
982
 
@@ -844,17 +987,79 @@ if (openBrowser && !RESPAWN) {
844
987
  // would leave the message behind and pulse into empty space. Sized to the real
845
988
  // terminal, because at 40 columns the old fixed 61-character line wrapped, and
846
989
  // from then on \r only ever reached its second row.
990
+ //
991
+ // AND IT STOPS MOVING (#742). The dot alternated green and grey every 800ms for
992
+ // as long as the deck ran, and a blinking indicator beside a status line is the
993
+ // vocabulary of "working on it" — so a boot that finished in a second read as
994
+ // one that never finished, which is what people reported. Motion is now spent
995
+ // on the two states where something really is outstanding, and the frame is
996
+ // compared against what is already on screen so a deck at rest paints once and
997
+ // then leaves the terminal alone. See pulseMoves.
847
998
  if (MOTION) {
848
999
  let pi = 0;
1000
+ let painted = null;
1001
+ // The line is on screen and is the last thing written to this terminal.
1002
+ let ours = false;
1003
+ // We are the one writing right now, so the guard below leaves us alone.
1004
+ let writing = false;
1005
+
1006
+ // The pulse line's tenancy, enforced rather than agreed.
1007
+ //
1008
+ // The convention was that anything with something to say writes a newline
1009
+ // first, so the pulse's `\r` never lands on somebody else's text. bin/deck.js
1010
+ // keeps it everywhere. src/server/quota.mjs does not — it calls console.error
1011
+ // directly — and a Windows user with no Claude Code sent a screenshot of the
1012
+ // result: `listening — Ctrl+C to stop ccdeck quota: claude CLI failed`
1013
+ // on one row, three times over, the pulse and the complaint interleaved.
1014
+ //
1015
+ // An invariant every writer has to remember is one a writer will forget, and
1016
+ // the writers here are server modules that know nothing about a terminal. So
1017
+ // it is enforced at the stream instead: while our line is the last thing on
1018
+ // screen, anything else that speaks gets a newline first, and the memo is
1019
+ // dropped so the next beat repaints the line under whatever was said.
1020
+ //
1021
+ // Both streams, because console.error goes to stderr and lands on the same
1022
+ // screen. Only when MOTION is on — with no pulse there is no line to defend,
1023
+ // and a piped deck must not have its output rewritten.
1024
+ for (const stream of [process.stdout, process.stderr]) {
1025
+ const real = stream.write.bind(stream);
1026
+ stream.write = (chunk, ...rest) => {
1027
+ if (!writing && ours) {
1028
+ ours = false;
1029
+ // Dropped, not kept: the line is no longer where we left it, so the
1030
+ // next beat has to draw it again even though the frame is unchanged.
1031
+ painted = null;
1032
+ // Unless the speaker already did it. Every late message in this file
1033
+ // opens with one, and two blank lines is its own kind of mess.
1034
+ if (!String(chunk).startsWith("\n")) real("\n");
1035
+ }
1036
+ return real(chunk, ...rest);
1037
+ };
1038
+ }
1039
+
849
1040
  setInterval(() => {
850
1041
  // The colour follows the words. A Codex-only deck keeps saying "listening"
851
1042
  // when it is unregistered — see pulseText — and painting that sentence in
852
1043
  // the warning tone would restore the alarm the sentence just retired.
853
1044
  const alarm = !registered && wantClaude;
854
- const text = pulseText({ registered, claude: wantClaude, columns: cols(), unicode: UNICODE });
855
- const dot = pi++ % 2 === 0 ? (alarm ? P.warn : P.ok) : P.muted;
1045
+ const state = { registered, claude: wantClaude, busy: pulseBusy };
1046
+ const text = pulseText({ ...state, columns: cols(), unicode: UNICODE });
1047
+ // At rest every beat is lit, which is what makes the line still: the frame
1048
+ // is then identical to the one already on screen and the write below is
1049
+ // skipped. See pulseDot.
1050
+ const dot = pulseDot(pi++, state) === "on" ? (alarm ? P.warn : P.ok) : P.muted;
856
1051
  const tone = alarm ? P.warn : P.muted;
857
- write(`\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`);
1052
+ const frame = `\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`;
1053
+ // Unchanged frames are not written at all. That is what makes "at rest"
1054
+ // visible: one paint, and then a still line for as long as nothing happens.
1055
+ // `painted` is dropped by the guard above whenever somebody else writes, so
1056
+ // this can only skip a beat while the line is genuinely still where we left
1057
+ // it.
1058
+ if (frame === painted) return;
1059
+ painted = frame;
1060
+ writing = true;
1061
+ try { write(frame); } finally { writing = false; }
1062
+ ours = true;
858
1063
  }, 800).unref();
859
1064
  }
860
1065