@opsee/cli 0.11.13 → 0.11.18

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.
@@ -0,0 +1,247 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { execFile } from "node:child_process";
4
+ import { homedir, userInfo } from "node:os";
5
+ import { join } from "node:path";
6
+ import { VENDOR_CREDENTIAL_FILE, type Vendor } from "./vendor.js";
7
+
8
+ /**
9
+ * Where a vendor keeps the credential for one config directory, per platform.
10
+ *
11
+ * This exists because of a wrong assumption that reached a release: that a subscription's
12
+ * credential is a file inside its config directory. **On macOS Claude Code writes no such file.**
13
+ * It derives a Keychain item per `CLAUDE_CONFIG_DIR` and stores the credential there, so a file
14
+ * check finds nothing however well the login went — which is exactly what refused three Accounts
15
+ * that had been logged into correctly.
16
+ *
17
+ * Two callers, wanting different things, and the difference is deliberate:
18
+ *
19
+ * - `account add --login` wants to know whether a login *happened* (`credentialPresence`). It never
20
+ * needs the secret, and never asks for one.
21
+ * - The usage poller wants the token itself (`readCredentialText`), which ADR-0013's amendment
22
+ * permits for the single purpose of asking that vendor about the user's own quota.
23
+ *
24
+ * Nothing else in the package may call `readCredentialText`.
25
+ */
26
+
27
+ /** The service name Claude Code uses for the default profile — no `CLAUDE_CONFIG_DIR` set. */
28
+ export const CLAUDE_DEFAULT_KEYCHAIN_SERVICE = "Claude Code-credentials";
29
+
30
+ /** The directory Claude Code uses when `CLAUDE_CONFIG_DIR` is not set. */
31
+ export const CLAUDE_DEFAULT_CONFIG_DIR = ".claude";
32
+
33
+ /**
34
+ * Apple's own binary, by absolute path.
35
+ *
36
+ * Never resolved through `PATH`: this reads a credential, so a `security` planted earlier on the
37
+ * path would be handed one. `/usr/bin/security` is present on every macOS.
38
+ */
39
+ const SECURITY = "/usr/bin/security";
40
+
41
+ /** A wedged Keychain — a locked login keychain on a headless or SSH host, prompting for an unlock
42
+ * nobody will give — must not hang a command. A healthy one answers in well under 100ms. */
43
+ const SECURITY_TIMEOUT_MS = 5_000;
44
+
45
+ /** `errSecItemNotFound`, as `security` surfaces it: the one non-zero status that means "no such
46
+ * item" rather than "I could not tell you". */
47
+ const NOT_FOUND_STATUS = 44;
48
+
49
+ /** What a credential lookup found. `unknown` is the important one: it means the question could not
50
+ * be answered, and **no caller may refuse anything on it**. */
51
+ export type CredentialPresence = "present" | "absent" | "unknown";
52
+
53
+ /** What `security` reported: its exit status, whatever it printed, and whether it ran at all. */
54
+ export interface SecurityResult {
55
+ /** Null when the command could not be run or did not finish. */
56
+ status: number | null;
57
+ stdout: string;
58
+ /** Set when the command was killed at `SECURITY_TIMEOUT_MS`, which a caller reports differently
59
+ * from a binary that is missing: one is a Keychain nobody can reach, the other is not macOS. */
60
+ timedOut?: boolean;
61
+ }
62
+
63
+ export interface CredentialDeps {
64
+ platform: NodeJS.Platform;
65
+ env: Readonly<Record<string, string | undefined>>;
66
+ /** The user's home, for recognising the default profile. Injected so the rule is testable. */
67
+ home: string;
68
+ /** The OS account name, used when the environment carries none (`osUsername`). */
69
+ osUsername: () => string;
70
+ fileExists: (path: string) => boolean;
71
+ readFile: (path: string) => string;
72
+ security: (args: readonly string[]) => Promise<SecurityResult>;
73
+ }
74
+
75
+ /**
76
+ * The real seams. Deliberately **not** a default parameter: `ConfigDirFs` and `HostProbeDeps` are
77
+ * both required arguments in this package, and a default is how a call site silently keeps the
78
+ * production implementation in a test — which is how the file-only assumption survived review.
79
+ */
80
+ export const realCredentialDeps: CredentialDeps = {
81
+ platform: process.platform,
82
+ env: process.env,
83
+ home: homedir(),
84
+ osUsername: () => userInfo().username,
85
+ fileExists: (path) => existsSync(path),
86
+ readFile: (path) => readFileSync(path, "utf-8"),
87
+ security: (args) =>
88
+ new Promise((resolve) => {
89
+ // `execFile`, not `spawnSync`: this runs inside the daemon, which is streaming live Workers.
90
+ // A wedged Keychain would otherwise block the event loop for the whole timeout, per Account
91
+ // — trading the thing that matters for the thing that helps, which the poll tick's own
92
+ // comment warns against.
93
+ execFile(SECURITY, [...args], { timeout: SECURITY_TIMEOUT_MS, encoding: "utf-8" }, (error, stdout) => {
94
+ const killed = (error as { killed?: boolean } | null)?.killed === true;
95
+ const status = (error as { code?: number } | null)?.code;
96
+ resolve({
97
+ status: error ? (typeof status === "number" ? status : null) : 0,
98
+ stdout: stdout ?? "",
99
+ ...(killed ? { timedOut: true } : {}),
100
+ });
101
+ });
102
+ }),
103
+ };
104
+
105
+ /**
106
+ * The Keychain service name Claude Code derives for a config directory.
107
+ *
108
+ * The vendor's own scheme: the first 8 hex characters of the SHA-256 of the **exact string exported
109
+ * as `CLAUDE_CONFIG_DIR`**, NFC-normalised, after a fixed prefix. Hash what is exported and nothing
110
+ * else — `/a/b` and `/a/b/` are different strings and so are different items, and resolving or
111
+ * tidying the path here would look up an item the vendor never wrote.
112
+ *
113
+ * The Foreman always exports the absolute path it stored at registration (`validateConfigDir`
114
+ * resolves it once, through the same `expandHome` the login path uses), so the string hashed here
115
+ * is the string the login used.
116
+ */
117
+ export function keychainServiceName(configDir: string): string {
118
+ const digest = createHash("sha256").update(configDir.normalize("NFC"), "utf8").digest("hex").slice(0, 8);
119
+ return `${CLAUDE_DEFAULT_KEYCHAIN_SERVICE}-${digest}`;
120
+ }
121
+
122
+ /**
123
+ * The Keychain account name, mirroring the vendor's `getUsername()`: `$USER`, then the OS account
124
+ * name, then a fixed string.
125
+ *
126
+ * The middle step is the one that matters, and it is not `$LOGNAME`. Under launchd — which is how
127
+ * `foreman service install` runs the daemon — neither `USER` nor `LOGNAME` is set, so an
128
+ * environment-only lookup would fall through to the fixed string, key a different item than the one
129
+ * the login wrote, and fail every poll on the machine. That is the original bug wearing a different
130
+ * hat, so the OS is asked rather than the environment.
131
+ */
132
+ export function keychainAccountName(deps: Pick<CredentialDeps, "env" | "osUsername">): string {
133
+ if (deps.env.USER) return deps.env.USER;
134
+ try {
135
+ const name = deps.osUsername();
136
+ if (name) return name;
137
+ } catch {
138
+ /* a container with no passwd entry; the fixed name below is the vendor's own last resort */
139
+ }
140
+ return "claude-code-user";
141
+ }
142
+
143
+ /**
144
+ * Whether a credential exists for this config directory.
145
+ *
146
+ * Three answers, and the third is the point: `unknown` means the question could not be answered —
147
+ * a locked Keychain, a denied prompt, a `security` that would not run. **A caller must not refuse
148
+ * anything on `unknown`.** Treating "I could not tell" as "there is nothing there" is precisely the
149
+ * bug this module was written to fix.
150
+ *
151
+ * Presence never asks for the value: on macOS the lookup omits `-w`, and off it the file is only
152
+ * stat-ed. Answering "is there one" by reading one would be a credential read with no permission
153
+ * behind it.
154
+ */
155
+ export async function credentialPresence(vendor: Vendor, configDir: string, deps: CredentialDeps): Promise<CredentialPresence> {
156
+ if (!usesKeychain(vendor, deps.platform)) {
157
+ return deps.fileExists(credentialPath(vendor, configDir)) ? "present" : "absent";
158
+ }
159
+ for (const service of keychainServices(configDir, deps)) {
160
+ const found = await lookUp(deps, service, { wantValue: false });
161
+ if (found.kind === "found") return "present";
162
+ if (found.kind === "unusable") return "unknown";
163
+ }
164
+ return "absent";
165
+ }
166
+
167
+ /**
168
+ * The credential as the vendor stored it, or a one-line reason.
169
+ *
170
+ * **The only credential read in this package**, and permitted by ADR-0013's amendment for one
171
+ * purpose: asking that same vendor about the user's own quota. Every failure reason is built here
172
+ * and quotes nothing that was read — a poller failure is written to the usage store, printed by
173
+ * `account usage` and logged, so a reason carrying the file or the item would leak into all three.
174
+ */
175
+ export async function readCredentialText(
176
+ vendor: Vendor,
177
+ configDir: string,
178
+ deps: CredentialDeps,
179
+ ): Promise<{ ok: true; text: string } | { ok: false; reason: string }> {
180
+ if (!usesKeychain(vendor, deps.platform)) {
181
+ try {
182
+ return { ok: true, text: deps.readFile(credentialPath(vendor, configDir)) };
183
+ } catch {
184
+ return { ok: false, reason: `no readable ${VENDOR_CREDENTIAL_FILE[vendor]} in ${configDir}` };
185
+ }
186
+ }
187
+ for (const service of keychainServices(configDir, deps)) {
188
+ const found = await lookUp(deps, service, { wantValue: true });
189
+ if (found.kind === "found") return { ok: true, text: found.value };
190
+ if (found.kind === "unusable") return { ok: false, reason: found.reason };
191
+ }
192
+ return { ok: false, reason: "no Keychain item holds a credential for this config directory; the vendor has not been logged in there" };
193
+ }
194
+
195
+ type LookUp = { kind: "found"; value: string } | { kind: "missing" } | { kind: "unusable"; reason: string };
196
+
197
+ /**
198
+ * One Keychain lookup. The only difference between asking whether an item exists and asking what is
199
+ * in it is `-w`, so both callers come through here rather than each keeping its own copy of the
200
+ * argument list and the three-way reading of the exit status.
201
+ */
202
+ async function lookUp(deps: CredentialDeps, service: string, options: { wantValue: boolean }): Promise<LookUp> {
203
+ const args = ["find-generic-password", "-a", keychainAccountName(deps), ...(options.wantValue ? ["-w"] : []), "-s", service];
204
+ const found = await deps.security(args);
205
+ // `-w` prints the value and one newline; strip exactly that.
206
+ if (found.status === 0) return { kind: "found", value: found.stdout.replace(/\n$/, "") };
207
+ if (found.status === NOT_FOUND_STATUS) return { kind: "missing" };
208
+ return {
209
+ kind: "unusable",
210
+ reason: found.timedOut
211
+ ? `the macOS Keychain did not answer within ${SECURITY_TIMEOUT_MS}ms for this Account`
212
+ : `the macOS Keychain could not be read for this Account (security exited ${found.status ?? "without running"})`,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Claude Code keeps credentials in the Keychain on macOS and in a file everywhere else. Codex is
218
+ * file-based on every platform (`auth.json`, vendor.ts), so it never comes here.
219
+ */
220
+ function usesKeychain(vendor: Vendor, platform: NodeJS.Platform): boolean {
221
+ return vendor === "claude" && platform === "darwin";
222
+ }
223
+
224
+ /**
225
+ * The Keychain items to try, in order.
226
+ *
227
+ * Normally exactly one: the item derived from this config directory. The default profile's
228
+ * unhashed item is tried **only when the directory is the default one**, which is the single case
229
+ * where both names can legitimately describe the same login: exporting `CLAUDE_CONFIG_DIR` makes
230
+ * the vendor write a hashed item, but somebody who has only ever used the default profile may have
231
+ * just the unhashed one.
232
+ *
233
+ * Trying it for every directory — which this did at first — is a hole, not a courtesy: a directory
234
+ * with no item of its own would have found the *default profile's* credential, so `--login` would
235
+ * register an Account nobody signed into, and the poller would file one identity's quota under
236
+ * another Account. Two Accounts would report one quota and the scheduler would believe twice the
237
+ * capacity that exists.
238
+ */
239
+ function keychainServices(configDir: string, deps: CredentialDeps): string[] {
240
+ const services = [keychainServiceName(configDir)];
241
+ if (configDir === join(deps.home, CLAUDE_DEFAULT_CONFIG_DIR)) services.push(CLAUDE_DEFAULT_KEYCHAIN_SERVICE);
242
+ return services;
243
+ }
244
+
245
+ function credentialPath(vendor: Vendor, configDir: string): string {
246
+ return join(configDir, VENDOR_CREDENTIAL_FILE[vendor]);
247
+ }
@@ -0,0 +1,102 @@
1
+ import { count } from "./core/text.js";
2
+ import type { AccountUsage, ActivityTurn } from "./usage.js";
3
+ import type { UsageStore } from "./usage-store.js";
4
+
5
+ /**
6
+ * What this Foreman has itself spent on an Account (the multi-account headroom design, §6).
7
+ *
8
+ * What the Foreman itself has spent on an Account, from the `costUsd` and turn count that already
9
+ * ride the completed event and were, until now, explicitly unconsumed.
10
+ *
11
+ * **It deliberately reports no percentage.** The design sketched this as a third estimator
12
+ * producing estimated window percentages for Accounts the poller cannot measure, and that
13
+ * part is not built, because it cannot be built honestly: converting "eleven turns and $4.20" into
14
+ * "38% of a five-hour window" needs the plan's limit, which nothing on this machine knows and which
15
+ * differs by subscription tier. A number like that would be invented, and — worse — it would sit in
16
+ * the same column as measurements that are real.
17
+ *
18
+ * What it is good for is the question a percentage cannot answer anyway: *how hard has the Foreman
19
+ * been leaning on this Account?* That is a fact, it is this machine's to know, and it is what a
20
+ * human looks for when an Account keeps hitting its limit. It is reported beside the windows in
21
+ * `foreman account usage`, never used to hold an Account back or to rank a Lane.
22
+ *
23
+ * Called activity on purpose: `cli/CONTEXT.md` puts "Ledger" on the avoid list
24
+ * for both the Run Record and the Process Table, and a third thing wearing the word would undo
25
+ * exactly the vocabulary that file exists to keep straight.
26
+ */
27
+
28
+ /** The windows this reports on, matching the vendor's own two. */
29
+ const FIVE_HOURS = 5 * 60 * 60_000;
30
+ const SEVEN_DAYS = 7 * 24 * 60 * 60_000;
31
+
32
+ /** The longest window reported on: a turn older than this answers no question asked here. */
33
+ const ACTIVITY_HORIZON_MS = SEVEN_DAYS;
34
+
35
+ /** The most turns kept for one Account. A busy fleet would otherwise grow the record without bound,
36
+ * and the oldest turns are the ones that have already aged out of every window that matters. */
37
+ export const ACTIVITY_TURN_CAP = 2_000;
38
+
39
+ /** Turns inside `windowMs`, and what they cost. A turn whose cost the vendor did not report still
40
+ * counts as a turn: the turn happened, and that is the part this is sure of. */
41
+ export function activityIn(usage: AccountUsage | undefined, windowMs: number, now: number): { turns: number; costUsd: number } {
42
+ let turns = 0;
43
+ let costUsd = 0;
44
+ for (const turn of usage?.activity?.turns ?? []) {
45
+ const at = Date.parse(turn.at);
46
+ if (!Number.isFinite(at) || at < now - windowMs || at > now) continue;
47
+ turns++;
48
+ if (typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd)) costUsd += turn.costUsd;
49
+ }
50
+ return { turns, costUsd };
51
+ }
52
+
53
+ /** What the Foreman has spent on this Account, in one line, or undefined when it has run none. */
54
+ export function summariseActivity(usage: AccountUsage | undefined, now: number): string | undefined {
55
+ const recent = activityIn(usage, FIVE_HOURS, now);
56
+ const week = activityIn(usage, SEVEN_DAYS, now);
57
+ if (week.turns === 0) return undefined;
58
+ const spent = week.costUsd > 0 ? `, $${week.costUsd.toFixed(2)} this week` : "";
59
+ return `this Foreman ran ${count(recent.turns, "turn")} in the last 5h and ${count(week.turns, "turn")} in 7d${spent}`;
60
+ }
61
+
62
+ /**
63
+ * Records one completed turn against an Account.
64
+ *
65
+ * Goes through the store's merge like everything else, so a turn recorded by one Run and a window
66
+ * measured by the poller at the same moment cannot lose each other (`mergeActivity`).
67
+ */
68
+ export function recordTurn(store: UsageStore, account: string, at: number, costUsd: number | undefined): void {
69
+ store.observe(account, {}, at, {
70
+ turns: [costUsd === undefined ? { at: new Date(at).toISOString() } : { at: new Date(at).toISOString(), costUsd }],
71
+ });
72
+ }
73
+
74
+ /** Turns still worth keeping: inside the horizon, newest-last, and no more than the cap. */
75
+ export function pruneTurns(turns: readonly ActivityTurn[], now: number): ActivityTurn[] {
76
+ const kept = turns
77
+ .filter((turn) => {
78
+ const at = Date.parse(turn.at);
79
+ return Number.isFinite(at) && at >= now - ACTIVITY_HORIZON_MS;
80
+ })
81
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
82
+ return kept.length > ACTIVITY_TURN_CAP ? kept.slice(kept.length - ACTIVITY_TURN_CAP) : kept;
83
+ }
84
+
85
+ /**
86
+ * Both writers' turns, in order, with duplicates dropped.
87
+ *
88
+ * The usage store has two writers — a Run recording a turn, the daemon's poller recording windows —
89
+ * and per-Account files keep them from losing each other's *Accounts*. This is what keeps them from
90
+ * losing each other's *turns*: a read-modify-write that overwrote the activity list would drop
91
+ * whatever the other process appended in between.
92
+ *
93
+ * A turn is identified by its instant and its cost. Two genuinely simultaneous turns of identical
94
+ * cost on one Account collapse into one, which costs the record a turn it would have counted; the
95
+ * alternative is an id per turn on a record that is already advisory, and this record is explicitly
96
+ * not a number anything depends on.
97
+ */
98
+ export function mergeActivity(mine: readonly ActivityTurn[] | undefined, theirs: readonly ActivityTurn[] | undefined, now: number): ActivityTurn[] {
99
+ const byKey = new Map<string, ActivityTurn>();
100
+ for (const turn of [...(mine ?? []), ...(theirs ?? [])]) byKey.set(`${turn.at}|${turn.costUsd ?? ""}`, turn);
101
+ return pruneTurns([...byKey.values()], now);
102
+ }
@@ -0,0 +1,107 @@
1
+ import { thresholdFor, type Account } from "../foreman/account.js";
2
+ import { formatTable, printableOneLine } from "./core/text.js";
3
+ import { summariseActivity } from "./usage-activity.js";
4
+ import { describeAge, describeUntil, headroom, thresholdHold, type AccountUsage } from "./usage.js";
5
+
6
+ /**
7
+ * How usage is rendered for a terminal.
8
+ *
9
+ * Out of `commands/account-usage.ts` because the command had grown four reasons to change — two
10
+ * table shapes, a measurement path and an empty-state message — against the seam the README states
11
+ * for `commands/initiative.ts`: a command parses arguments and prints, and formatting that turns up
12
+ * in one is the seam asking for a module of its own.
13
+ */
14
+ /**
15
+ * One line per Account: what is left, which window decides that, when it resets, and the state.
16
+ *
17
+ * Headroom is the tightest live window's, which is the number every dispatch decision uses — an
18
+ * Account at 20% of its five-hour and 98% of its seven-day has 2% to spend, not 41%.
19
+ */
20
+ export function summaryRows(accounts: Account[], known: Map<string, AccountUsage>, now: number): string[] {
21
+ const rows: string[][] = [["ACCOUNT", "HEADROOM", "TIGHTEST", "RESETS", "STATE"]];
22
+ for (const account of accounts) {
23
+ const record = known.get(account.name);
24
+ if (account.type === "api_key") {
25
+ rows.push([account.name, "-", "-", "-", "API key: no usage is reported for it, and it is never held back"]);
26
+ continue;
27
+ }
28
+ const left = headroom(record, now);
29
+ if (left.kind === "unknown") {
30
+ rows.push([account.name, "-", "-", "-", failureNote(record, now) ?? "never observed"]);
31
+ continue;
32
+ }
33
+ const hold = thresholdHold(record, thresholdFor(account), now);
34
+ const notes = [
35
+ hold ? `held at ${hold.reason}` : "active",
36
+ describeAge(left.observedAt, now) === "just now" ? "" : `seen ${describeAge(left.observedAt, now)}`,
37
+ failureNote(record, now) ?? "",
38
+ summariseActivity(record, now) ?? "",
39
+ ].filter(Boolean);
40
+ rows.push([account.name, `${left.remainingPct}%`, left.tightest, describeUntil(left.resetsAt, now), notes.join("; ")]);
41
+ }
42
+ return formatTable(rows, ["left", "right", "left", "right"]);
43
+ }
44
+
45
+ /**
46
+ * A row per window: what `--windows` is for.
47
+ *
48
+ * Freshness and provenance are on every line on purpose. A percentage with no age reads as current
49
+ * however old it is, and the sources are not equally trustworthy: `in_band` is what the vendor told
50
+ * a Worker, `polled` is a measurement of an idle Account. The `foreman` row beneath them is not a
51
+ * source at all — it is this Foreman's own tally of what it spent, and states no percentage
52
+ * (usage-activity.ts says why it cannot honestly state one).
53
+ */
54
+ export function windowRows(accounts: Account[], known: Map<string, AccountUsage>, now: number): string[] {
55
+ const rows: string[][] = [["ACCOUNT", "WINDOW", "USED", "LEFT", "RESETS", "SEEN", "SOURCE", "STATE"]];
56
+ for (const account of accounts) {
57
+ const record = known.get(account.name);
58
+ // An API-key Account is not an unmeasured subscription: there is no window to measure, it is
59
+ // never rate limited as far as the Foreman can see, and the usage-aware strategies must not
60
+ // read it as spent. Said in words so nobody reads a blank row as "exhausted".
61
+ if (account.type === "api_key") {
62
+ rows.push([account.name, "-", "-", "-", "-", "-", "-", "API key: no usage is reported for it, and it is never held back"]);
63
+ continue;
64
+ }
65
+ const windows = Object.entries(record?.windows ?? {});
66
+ if (windows.length === 0) {
67
+ rows.push([account.name, "-", "-", "-", "-", "-", "-", failureNote(record, now) ?? "never observed"]);
68
+ continue;
69
+ }
70
+ const hold = thresholdHold(record, thresholdFor(account), now);
71
+ const left = headroom(record, now);
72
+ for (const [name, window] of windows.sort(([a], [b]) => a.localeCompare(b))) {
73
+ const live = Date.parse(window.resetsAt) > now;
74
+ const state = !live
75
+ ? "window has reset; the number is last window's"
76
+ : hold?.window === name
77
+ ? `held back until it resets (at or past the ${thresholdFor(account)}% threshold)`
78
+ : left.kind === "known" && left.tightest === name
79
+ ? "the tightest window, so this is what decides dispatch"
80
+ : "";
81
+ rows.push([
82
+ account.name,
83
+ name,
84
+ `${window.usedPct}%`,
85
+ live ? `${Math.max(0, Math.min(100, 100 - window.usedPct))}%` : "-",
86
+ live ? describeUntil(window.resetsAt, now) : "-",
87
+ describeAge(window.observedAt, now),
88
+ window.source,
89
+ state,
90
+ ]);
91
+ }
92
+ const failure = failureNote(record, now);
93
+ if (failure) rows.push([account.name, "-", "-", "-", "-", "-", "-", failure]);
94
+ // This Foreman's own tally beside the vendor's windows, and clearly not one of them: it is what
95
+ // was spent, never a share of a limit.
96
+ const activity = summariseActivity(record, now);
97
+ if (activity) rows.push([account.name, "-", "-", "-", "-", "-", "foreman", activity]);
98
+ }
99
+ return formatTable(rows, ["left", "left", "right", "right", "right", "right"]);
100
+ }
101
+
102
+ /** The last poll failure in one line, so a reader can tell a stale number from a current one. */
103
+ function failureNote(record: AccountUsage | undefined, now: number): string | undefined {
104
+ if (!record?.pollFailedAt) return undefined;
105
+ const waiting = record.pollBackoffUntil && Date.parse(record.pollBackoffUntil) > now ? `, not asking again until ${describeUntil(record.pollBackoffUntil, now)}` : "";
106
+ return `last poll failed ${describeAge(record.pollFailedAt, now)}: ${printableOneLine(record.pollFailure ?? "no reason given")}${waiting}`;
107
+ }