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.
@@ -528,6 +528,37 @@ async function nudgeAndReread(previous) {
528
528
  // cliOk — the CLI ran and we recognized its output (preamble present)
529
529
  // parsed — quota percentages object, or null if the "Current session/week"
530
530
  // lines were absent (CLI cold-start, or genuinely <1% usage)
531
+ /**
532
+ * The failure this last said out loud, so a standing one is said once.
533
+ *
534
+ * #742. A Windows user with no Claude Code installed sent a screenshot of three
535
+ * identical lines — `ccdeck quota: claude CLI failed: claude exited ENOENT` —
536
+ * interleaved with the deck's pulse line, and they keep coming for as long as
537
+ * the deck runs. Every poll ran the loop below three times, and every attempt
538
+ * printed. A CLI that is not installed is not news three times a minute; it is
539
+ * a condition, and a condition is worth exactly one line.
540
+ *
541
+ * Cleared on the first run that works, so a `claude` installed while the deck
542
+ * is up can still report its next genuine failure.
543
+ */
544
+ let _saidFailure = null;
545
+
546
+ /** Exported for its test, and for the same reason resetCswapBin is: a module
547
+ * that remembers something across calls needs a way to be asked twice.
548
+ *
549
+ * Deliberately NOT folded into invalidateQuotaCache, which production calls
550
+ * after an account switch — forgetting the notice there would put the same
551
+ * sentence back on the terminal every time somebody changed accounts. */
552
+ export function forgetQuotaFailureNotice() { _saidFailure = null; }
553
+
554
+ /** The rate floor, cleared. `maySelfPoll` keeps a self-poll to one a minute
555
+ * even under `force`, which is correct for a user's budget and is a test
556
+ * asking the same question three times running into a wall. */
557
+ export function resetQuotaPollFloor() {
558
+ _lastSelfPollAt = 0;
559
+ _rateLimitedUntil = 0;
560
+ }
561
+
531
562
  async function _execOnce(bin) {
532
563
  const r = await run(bin, ["--print", "/usage"], {
533
564
  timeout: 15_000,
@@ -543,12 +574,22 @@ async function _execOnce(bin) {
543
574
  // is kept either way, which matters because the CLI writes the quota lines to
544
575
  // stdout and can still exit non-zero afterwards.
545
576
  const combined = r.stdout + "\n" + r.stderr;
577
+ // `run` normalises a binary that is not there to this, on every platform —
578
+ // see exec.mjs. It is the difference between "Claude Code answered badly",
579
+ // which is worth retrying and worth saying, and "there is no Claude Code on
580
+ // this machine", which is neither.
581
+ const missing = r.code === "ENOENT";
546
582
  if (!r.ok) {
547
583
  const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
548
- console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
584
+ if (msg !== _saidFailure) {
585
+ _saidFailure = msg;
586
+ console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
587
+ }
588
+ } else {
589
+ _saidFailure = null;
549
590
  }
550
591
  const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
551
- return { cliOk, parsed: parseUsageText(combined) };
592
+ return { cliOk, missing, parsed: parseUsageText(combined) };
552
593
  }
553
594
 
554
595
  async function _doFetch(now, force = false, gen = _generation) {
@@ -607,6 +648,11 @@ async function _doFetch(now, force = false, gen = _generation) {
607
648
  const r = await _execOnce(bin);
608
649
  cliOk = r.cliOk || cliOk;
609
650
  if (r.parsed) { parsed = r.parsed; break; }
651
+ // The retry exists for a CLI that RAN and left the quota lines out of a cold
652
+ // invocation. A CLI that is not installed will not be installed 1.2 seconds
653
+ // from now, and asking twice more spends two spawns and 2.4 seconds of the
654
+ // caller's wait to print the same sentence three times. See _execOnce.
655
+ if (r.missing) break;
610
656
  }
611
657
 
612
658
  // Got real quota lines — cache normally and remember as last-known-good.
@@ -663,11 +663,37 @@ async function readProcessesNow(platform) {
663
663
  // Intel-only and an undocumented scale, and printing it as though it
664
664
  // were degrees would be exactly the lie this module refuses.
665
665
  //
666
- // Windows MSAcpi_ThermalZoneTemperature in root/wmi, CurrentTemperature in
667
- // TENTHS OF A KELVIN. Published by the firmware and genuinely absent
668
- // on a large share of desktop boards — the same lesson
669
- // parseGetProcessJson learned about perflib, which is why absent is
670
- // ordinary here rather than an error.
666
+ // Windows The performance counter `\Thermal Zone Information(*)\High
667
+ // Precision Temperature`, in TENTHS OF A KELVIN, read through
668
+ // Get-Counter.
669
+ //
670
+ // It is read through a counter rather than through WMI for one
671
+ // reason, and it is the reason this section never worked on Windows
672
+ // for anybody: MSAcpi_ThermalZoneTemperature lives in root\wmi and
673
+ // that namespace REQUIRES ADMINISTRATOR. A deck started from an
674
+ // ordinary terminal — which is every deck, since v1 must never need
675
+ // admin rights — got Access Denied, three times, and then gave up
676
+ // for the life of the process. The section was not missing because
677
+ // the hardware was silent; it was missing because we were asking
678
+ // somewhere we were not allowed to look.
679
+ //
680
+ // MSAcpi is still asked, second, because a deck that IS elevated can
681
+ // read it and because the two do not always agree — some boards
682
+ // publish a zone to ACPI and no counter.
683
+ //
684
+ // Genuinely absent on a large share of machines either way: a
685
+ // desktop board with no zone, and every virtual machine, which has
686
+ // no thermal hardware to report. Measured on a QEMU/SeaBIOS guest:
687
+ // the counter set is registered and answers "The specified instance
688
+ // is not present", and MSAcpi answers "Not supported" even to an
689
+ // administrator. Absent is ordinary here rather than an error.
690
+ //
691
+ // THE COUNTER PATH IS LOCALISED. `Thermal Zone Information` is the
692
+ // English name and a German or French Windows publishes its own, so
693
+ // this reaches the counter on an English install and falls through
694
+ // to MSAcpi elsewhere. Translating it means resolving a numeric
695
+ // index through the registry, which is a change with no way to be
696
+ // tested from here — see #747.
671
697
  //
672
698
  // NEVER INVENT A READING. No sensor means no row, and no rows at all means the
673
699
  // section is not rendered: not 0°C, not a dash, not a grey empty bar. Same rule
@@ -882,11 +908,96 @@ export function throttleFromPmset(text) {
882
908
  return { speedLimit: pct };
883
909
  }
884
910
 
911
+
912
+ /**
913
+ * Windows thermal zones out of the `Thermal Zone Information` counter set.
914
+ *
915
+ * `High Precision Temperature` is in TENTHS OF A KELVIN, which is the single
916
+ * detail this branch turns on — the same unit MSAcpi uses below, and reading it
917
+ * as anything else gives a number that is plausible-looking and wrong.
918
+ *
919
+ * This is the source that works WITHOUT ADMINISTRATOR, which is the whole point
920
+ * of it: root\wmi needs elevation and a deck never has it. See the note at the
921
+ * top of this section.
922
+ *
923
+ * Shape is `[{ i: instanceName, v: cookedValue }]` — the projection the
924
+ * PowerShell one-liner makes, so this parser never has to know what a
925
+ * CounterSample looks like.
926
+ */
927
+ export const WIN_THERMAL_PS = [
928
+ "$r = [ordered]@{}",
929
+ // Get-Counter, not Get-CimInstance: this is the half that works unelevated.
930
+ "try { $r.perf = @((Get-Counter -Counter '\\Thermal Zone Information(*)\\High Precision Temperature' -EA Stop).CounterSamples | ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } catch {}",
931
+ "if (-not $r.perf) { try { $r.acpi = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -EA Stop | ForEach-Object { @{ i = $_.InstanceName; v = $_.CurrentTemperature } }) } catch {} }",
932
+ // Depth matters: the default of 2 turns the inner hashtables into the string
933
+ // "System.Collections.Hashtable" and this parser would see nothing at all.
934
+ "$r | ConvertTo-Json -Compress -Depth 4",
935
+ ].join("; ");
936
+
937
+ /**
938
+ * Whichever of the two Windows sources answered, as thermal rows.
939
+ *
940
+ * The shape is `{ perf: [...] }` or `{ acpi: [...] }` or `{}` — the PowerShell
941
+ * above only ever fills one, and fills neither on the machines where there is
942
+ * nothing to fill it with. Both lists carry the same two fields and the same
943
+ * unit, so the only thing that differs is which key they arrived under.
944
+ */
945
+ export function parseWinThermal(json) {
946
+ let r;
947
+ try { r = typeof json === "string" ? JSON.parse(json) : json; }
948
+ catch { return []; }
949
+ if (!r || typeof r !== "object") return [];
950
+ const perf = tempFromPerfCounterJson(r.perf ?? []);
951
+ if (perf.length) return perf;
952
+ return tempFromPerfCounterJson(
953
+ // MSAcpi's projection uses the same two keys, so the one parser reads both.
954
+ Array.isArray(r.acpi) ? r.acpi : (r.acpi ? [r.acpi] : []),
955
+ );
956
+ }
957
+
958
+ export function tempFromPerfCounterJson(json) {
959
+ let rows;
960
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
961
+ catch { return []; }
962
+ if (!rows) return [];
963
+ if (!Array.isArray(rows)) rows = [rows];
964
+ const found = [];
965
+ for (const r of rows) {
966
+ const tenths = Number(r?.v);
967
+ if (!Number.isFinite(tenths)) continue;
968
+ const celsius = Math.round(tenths / 10 - 273.15);
969
+ if (!plausible(celsius)) continue;
970
+ found.push({ label: zoneLabel(r?.i), celsius, warnAt: WARN_C, critAt: CRIT_C });
971
+ }
972
+ if (found.length === 1) found[0].label = "Thermal zone";
973
+ return found.slice(0, 2);
974
+ }
975
+
976
+ /**
977
+ * A thermal zone's name, out of whatever spelling the source used.
978
+ *
979
+ * The counter names its instances `\_tz.tz00` and WMI names the same zone
980
+ * `ACPI\ThermalZone\TZ00_0`, so the tail after the last separator is the only
981
+ * part the two agree on. Upper-cased because the counter lower-cases it and a
982
+ * panel that showed `tz00` beside a `TZ01` from the other source would be
983
+ * showing one machine as two.
984
+ */
985
+ export function zoneLabel(raw) {
986
+ const tail = String(raw ?? "").split(/[\\.]/).pop() ?? "";
987
+ const name = tail.replace(/_\d+$/, "").toUpperCase();
988
+ return name || "Thermal zone";
989
+ }
990
+
885
991
  /**
886
992
  * Windows thermal zones out of MSAcpi_ThermalZoneTemperature.
887
993
  *
888
- * CurrentTemperature is in tenths of a Kelvin, which is the single detail this
889
- * whole branch turns on: reading it as anything else gives a number that is
994
+ * The SECOND source, and only reachable by a deck that happens to be elevated:
995
+ * root\\wmi requires administrator and refuses an ordinary terminal outright.
996
+ * Kept because some boards publish a zone to ACPI and no counter, and because a
997
+ * deck launched from an elevated shell can read it.
998
+ *
999
+ * CurrentTemperature is in tenths of a Kelvin, the same unit as the counter
1000
+ * above, and reading it as anything else gives a number that is
890
1001
  * plausible-looking and wrong.
891
1002
  *
892
1003
  * The zone is labelled "Thermal zone" when there is one and by its own name
@@ -906,13 +1017,37 @@ export function tempFromMsAcpiJson(json) {
906
1017
  if (!Number.isFinite(k)) continue;
907
1018
  const celsius = Math.round(k / 10 - 273.15);
908
1019
  if (!plausible(celsius)) continue;
909
- const name = String(r?.InstanceName ?? "").split("\\").pop().replace(/_\d+$/, "");
910
- found.push({ label: name || "Thermal zone", celsius, warnAt: WARN_C, critAt: CRIT_C });
1020
+ found.push({ label: zoneLabel(r?.InstanceName), celsius, warnAt: WARN_C, critAt: CRIT_C });
911
1021
  }
912
1022
  if (found.length === 1) found[0].label = "Thermal zone";
913
1023
  return found.slice(0, 2);
914
1024
  }
915
1025
 
1026
+ /**
1027
+ * The macOS rows, from whatever the three sources answered.
1028
+ *
1029
+ * Pure, and exported, for the reason sampleThermal's `deps.read` is: the branch
1030
+ * that matters only fires on a machine that answers with nothing, and the
1031
+ * machine this was written on answers with something. There is no other way to
1032
+ * run it.
1033
+ *
1034
+ * The ordering rule is the whole content. ioreg's GPU degrees and pmset's
1035
+ * throttle are what macOS itself gives up, and they win — they cost one cheap
1036
+ * subprocess each and they are the same numbers this deck has always shown.
1037
+ * macmon is consulted only when both were silent, and then its CPU row comes
1038
+ * first, because on the machine that reaches here the CPU is the reading
1039
+ * somebody opened the panel for.
1040
+ */
1041
+ export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}) {
1042
+ const celsius = [];
1043
+ if (gpuC != null) celsius.push({ label: "GPU", celsius: gpuC, warnAt: WARN_C, critAt: CRIT_C });
1044
+ else {
1045
+ if (macmon.cpu != null) celsius.push({ label: "CPU", celsius: macmon.cpu, warnAt: WARN_C, critAt: CRIT_C });
1046
+ if (macmon.gpu != null) celsius.push({ label: "GPU", celsius: macmon.gpu, warnAt: WARN_C, critAt: CRIT_C });
1047
+ }
1048
+ return celsius.length || throttle ? { celsius, throttle } : null;
1049
+ }
1050
+
916
1051
  /**
917
1052
  * What /api/system carries, or null when this machine says nothing at all.
918
1053
  *
@@ -937,21 +1072,45 @@ export async function readThermal(platform = process.platform) {
937
1072
  run("ioreg", ["-r", "-k", "PerformanceStatistics", "-w", "0"], 3_000),
938
1073
  run("pmset", ["-g", "therm"]),
939
1074
  ]);
940
- const celsius = [];
941
- const c = gpu ? gpuFromIoreg(gpu) : null;
942
- if (c != null) celsius.push({ label: "GPU", celsius: c, warnAt: WARN_C, critAt: CRIT_C });
1075
+ const gpuC = gpu ? gpuFromIoreg(gpu) : null;
943
1076
  const throttle = therm ? throttleFromPmset(therm) : null;
944
- return celsius.length || throttle ? { celsius, throttle } : null;
1077
+
1078
+ // Nothing from either is every Apple Silicon Mac, and only those: the AGX
1079
+ // driver does not publish the key ioreg reads, and pmset records no speed
1080
+ // limit on M-series. Asking macmon is the only thing left, and it is asked
1081
+ // ONLY here — an Intel Mac answers above and never spawns it. See
1082
+ // macmon.mjs for why a tool the user installed is the whole of the answer.
1083
+ const macmon = gpuC == null && !throttle
1084
+ ? await (await import("./macmon.mjs")).readMacmonTemps()
1085
+ : {};
1086
+
1087
+ return darwinThermal({ gpuC, throttle, macmon });
945
1088
  }
946
1089
 
947
1090
  if (platform === "win32") {
1091
+ // One child for both sources rather than two, because the cost here is the
1092
+ // PowerShell start and not the queries: the counter is tried first because
1093
+ // it needs no administrator, and MSAcpi only when the counter said nothing.
1094
+ // Both are wrapped in their own try — "no thermal zone on this machine" is
1095
+ // the ordinary answer and arrives as a throw from either.
948
1096
  const out = await run("powershell.exe", [
949
- "-NoProfile", "-NonInteractive", "-Command",
950
- "Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction Stop | Select-Object InstanceName,CurrentTemperature | ConvertTo-Json -Compress",
1097
+ "-NoProfile", "-NonInteractive", "-Command", WIN_THERMAL_PS,
951
1098
  ], 6_000);
952
- if (!out) return null;
953
- const celsius = tempFromMsAcpiJson(out.trim());
954
- return celsius.length ? { celsius, throttle: null } : null;
1099
+ const answer = out ? parseWinThermal(out.trim()) : [];
1100
+ if (answer.length) return { celsius: answer, throttle: null };
1101
+
1102
+ // Windows itself had nothing, which on a modern Intel laptop is every time:
1103
+ // the firmware declares no ACPI thermal zone and the sensors sit behind
1104
+ // Intel DTT, which an ordinary process may not read. If something on this
1105
+ // machine has already gone and got them — LibreHardwareMonitor, with its
1106
+ // web server on — they are a plain HTTP read away. Never installed, never
1107
+ // asked for. See hwmonitor.mjs.
1108
+ const { readHwMonitorTemps } = await import("./hwmonitor.mjs");
1109
+ const t = await readHwMonitorTemps();
1110
+ const rows = [];
1111
+ if (t.cpu != null) rows.push({ label: "CPU", celsius: t.cpu, warnAt: WARN_C, critAt: CRIT_C });
1112
+ if (t.gpu != null) rows.push({ label: "GPU", celsius: t.gpu, warnAt: WARN_C, critAt: CRIT_C });
1113
+ return rows.length ? { celsius: rows, throttle: null } : null;
955
1114
  }
956
1115
 
957
1116
  return null;
@@ -1186,11 +1345,45 @@ export async function sampleThermal(deps = {}) {
1186
1345
  clearInterval(thermalTimer);
1187
1346
  thermalTimer = null;
1188
1347
  }
1348
+ // The one machine where "nothing" is worth doing something about: an
1349
+ // Apple Silicon Mac has sensors and no way to read them, and the tool
1350
+ // that can is a 746 KB signed binary this deck can fetch. Started HERE
1351
+ // rather than at boot on purpose — the boot was just taught not to wait
1352
+ // for an install (#742) and nothing waits for this one either. One
1353
+ // attempt per process, and only after the give-up, so a machine that
1354
+ // does have a sensor never downloads anything. See macmon.mjs.
1355
+ if (!thermalEverAnswered && process.platform === "darwin") fetchMacmon(deps);
1189
1356
  }
1190
1357
  } catch { thermalMisses++; }
1191
1358
  finally { thermalInFlight = false; }
1192
1359
  }
1193
1360
 
1361
+ /**
1362
+ * Fetch macmon, then ask again — floating, on purpose.
1363
+ *
1364
+ * Not awaited by sampleThermal, which is itself not awaited by anything: this
1365
+ * is a download that may take a minute on a slow line, and the panel it serves
1366
+ * is optional. When it lands, the give-up above has already stopped the timer,
1367
+ * so the retry has to be made here rather than waited for.
1368
+ */
1369
+ function fetchMacmon(deps = {}) {
1370
+ const boot = deps.bootstrap ?? (async () => (await import("./macmon.mjs")).bootstrapMacmon());
1371
+ Promise.resolve(boot()).then(r => {
1372
+ if (!r?.ok) return;
1373
+ // A sensor exists after all. Clear the give-up and let the timer run again,
1374
+ // which is what turns a downloaded binary into a section on screen without
1375
+ // the user restarting anything.
1376
+ thermalMisses = 0;
1377
+ if (!thermalTimer) {
1378
+ thermalTimer = setInterval(() => { sampleThermal(deps); }, THERMAL_INTERVAL_MS);
1379
+ // Unref'd like the one startSystemMetrics creates: a poll for an optional
1380
+ // panel must not be the reason a process refuses to exit.
1381
+ thermalTimer.unref?.();
1382
+ }
1383
+ sampleThermal(deps);
1384
+ }).catch(() => {});
1385
+ }
1386
+
1194
1387
  async function sampleMemory() {
1195
1388
  if (memInFlight) return;
1196
1389
  memInFlight = true;
@@ -217,6 +217,29 @@ export function spinnerFrames(unicode) {
217
217
  return unicode ? BRAILLE_FRAMES.slice() : ASCII_FRAMES.slice();
218
218
  }
219
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
+
220
243
  // ── motion ───────────────────────────────────────────────────────────────────
221
244
 
222
245
  /**
@@ -517,12 +540,14 @@ export function wordmark({
517
540
  * use. The one-time report at boot still says what IS lost; see
518
541
  * unregisteredDetail.
519
542
  */
520
- export function pulseText({ registered = true, claude = true, columns = 80, unicode = true, indent = 2 } = {}) {
543
+ export function pulseText({
544
+ registered = true, claude = true, columns = 80, unicode = true, indent = 2, busy = null,
545
+ } = {}) {
521
546
  const g = glyphs(unicode);
522
547
  const room = Math.max(4, columns - indent - 3 - 1);
523
548
  const pick = (options) => options.find((o) => o.length <= room) ?? options[options.length - 1].slice(0, room);
524
549
 
525
- const ok = pick([`listening ${g.dash} Ctrl+C to stop`, "listening"]);
550
+ const rest = pick([`listening ${g.dash} Ctrl+C to stop`, "listening"]);
526
551
  const bad = pick([
527
552
  `listening, but not registered ${g.dash} hooks cannot find this deck`,
528
553
  `not registered ${g.dash} hooks cannot find this deck`,
@@ -531,10 +556,61 @@ export function pulseText({ registered = true, claude = true, columns = 80, unic
531
556
  // Both branches are still measured, registered or not, because the line is
532
557
  // redrawn over itself and the shorter message has to cover the longer one on
533
558
  // the beat after a deck loses its registration.
534
- const width = Math.min(room, Math.max(ok.length, bad.length));
559
+ //
560
+ // The width is deliberately computed from the two FIXED messages only. `busy`
561
+ // comes and goes on a single boot — an install starts, the line names it, the
562
+ // install ends and the line goes back to Ctrl+C — so a width that grew to fit
563
+ // the label would have to shrink again afterwards, and the shorter line would
564
+ // leave the tail of the longer one on screen. Instead the label is shown only
565
+ // where it already fits — 60 columns and wider, measured — and below that the
566
+ // line says the true thing it has always said.
567
+ const width = Math.min(room, Math.max(rest.length, bad.length));
568
+ // What is still happening, rather than what is always true. `Ctrl+C to stop`
569
+ // is the right thing to say to somebody with nothing left to wait for, and
570
+ // the wrong thing to say to somebody watching an install.
571
+ const label = typeof busy === "string" && busy.trim() ? busy.trim() : null;
572
+ const working = label ? `listening ${g.bullet} ${label}` : null;
573
+ const ok = working && working.length <= width ? working : rest;
535
574
  return (registered || !claude ? ok : bad).padEnd(width);
536
575
  }
537
576
 
577
+ /**
578
+ * Whether the line has anything left to say by moving.
579
+ *
580
+ * #742. The dot alternated green and grey every 800ms for as long as the deck
581
+ * ran, and a blinking indicator beside a status line is the vocabulary of
582
+ * "working on it" — so a boot that had finished in a second read as one that
583
+ * never finished, and people said so. The deck's own web UI already retired
584
+ * this once: the pill goes quiet at rest (#720). The terminal did not.
585
+ *
586
+ * Motion is now spent on the two states where something is genuinely
587
+ * outstanding — a deck no hook can find, and a background job still running —
588
+ * and nowhere else. At rest the dot is painted once, in the healthy colour, and
589
+ * left alone. Movement then means something changed, which is the only thing
590
+ * movement should ever mean on a line somebody leaves open for hours.
591
+ */
592
+ export function pulseMoves({ registered = true, claude = true, busy = null } = {}) {
593
+ if (!registered && claude) return true;
594
+ return typeof busy === "string" && busy.trim() !== "";
595
+ }
596
+
597
+ /**
598
+ * Whether the dot is lit on this beat.
599
+ *
600
+ * `"on"` on every beat of a deck at rest, which is what makes the line still:
601
+ * bin/deck.js paints a beat only when the frame differs from the one already on
602
+ * screen, so a dot that is always lit is a line written once and then left
603
+ * alone. Alternating is reserved for the states pulseMoves admits.
604
+ *
605
+ * The beat is a parameter rather than counted here so this is a function of its
606
+ * inputs and nothing else — and so a test can ask what the twentieth beat of an
607
+ * idle deck looks like without waiting sixteen seconds for it.
608
+ */
609
+ export function pulseDot(beat, { registered = true, claude = true, busy = null } = {}) {
610
+ if (!pulseMoves({ registered, claude, busy })) return "on";
611
+ return beat % 2 === 0 ? "on" : "off";
612
+ }
613
+
538
614
  /**
539
615
  * The second line of the "not registered" report, which bin/deck.js prints once
540
616
  * per change of state rather than every beat.