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 +33 -0
- package/bin/deck.js +266 -61
- package/dist/web/assets/{index-6cAJDiBs.js → index-7UsJp5Ht.js} +40 -22
- package/dist/web/index.html +1 -1
- package/package.json +1 -4
- package/release-notes.json +28 -0
- package/src/server/boot-deadline.mjs +127 -0
- package/src/server/cswap-install.mjs +61 -8
- package/src/server/hwmonitor.mjs +56 -0
- package/src/server/index.mjs +102 -23
- package/src/server/lhm-parse.mjs +91 -0
- package/src/server/log-tail.mjs +139 -0
- package/src/server/macmon.mjs +299 -0
- package/src/server/open-url.mjs +226 -0
- package/src/server/quota.mjs +48 -2
- package/src/server/system-metrics.mjs +211 -18
- package/src/server/term.mjs +79 -3
package/src/server/quota.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
667
|
-
// TENTHS OF A KELVIN
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
//
|
|
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
|
-
*
|
|
889
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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;
|
package/src/server/term.mjs
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
-
|
|
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.
|