ccdeck 3.29.11 → 3.31.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 +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Whether Anthropic or OpenAI say something is wrong on their side (#1311).
|
|
2
|
+
//
|
|
3
|
+
// When Claude or Codex has an incident, everything this deck can see of it is
|
|
4
|
+
// a local symptom — a quota read that fails, an agent that errors and retries,
|
|
5
|
+
// a number that stops moving — and nothing here said the cause might be
|
|
6
|
+
// upstream. So an outage looked like the user's machine, network, login or
|
|
7
|
+
// this deck. Both providers publish their health on a page that serves the
|
|
8
|
+
// Atlassian Statuspage API (OpenAI's page is incident.io, which answers the
|
|
9
|
+
// same /api/v2/summary.json), and this module asks that page and boils the
|
|
10
|
+
// answer down to one line per provider.
|
|
11
|
+
//
|
|
12
|
+
// SCOPED TO THE COMPONENTS A DECK USES. OpenAI's page covers ChatGPT, the ads
|
|
13
|
+
// products, image generation and twenty more, and its page-wide indicator goes
|
|
14
|
+
// amber for a ChatGPT outage no Codex session will ever feel. So the state is
|
|
15
|
+
// the worst of the NAMED components — Claude Code and the Claude API on
|
|
16
|
+
// Anthropic's page, the Codex ones on OpenAI's — and the page-wide indicator is
|
|
17
|
+
// a fallback only, for the day a page renames every one of them.
|
|
18
|
+
//
|
|
19
|
+
// ON DEMAND, NOT ON A TIMER. Nothing in this file polls. A read goes out when
|
|
20
|
+
// the page or the desktop tray asks and the last answer is older than
|
|
21
|
+
// FRESH_MS, so a deck with nobody looking at it asks nobody anything, and the
|
|
22
|
+
// page stops asking while its tab is hidden (use-provider-status.ts).
|
|
23
|
+
//
|
|
24
|
+
// A FAILED READ IS NOT AN INCIDENT. An unreachable status page says nothing
|
|
25
|
+
// about the provider — far more often it is the user's own network — so a
|
|
26
|
+
// failure keeps the last answer and marks it stale, and past EXPIRE_MS drops it
|
|
27
|
+
// to "unknown", which draws nothing. It never becomes an outage, and nothing
|
|
28
|
+
// here throws: this is context for a diagnosis, and no other part of the deck
|
|
29
|
+
// waits on it.
|
|
30
|
+
|
|
31
|
+
/** The two pages, what each page is linked as, and which of its components
|
|
32
|
+
* are the ones this deck's sessions depend on. */
|
|
33
|
+
export const PROVIDERS = {
|
|
34
|
+
claude: {
|
|
35
|
+
summaryUrl: "https://status.claude.com/api/v2/summary.json",
|
|
36
|
+
pageUrl: "https://status.claude.com",
|
|
37
|
+
// claude.ai, the Console and the government and Cowork products are left
|
|
38
|
+
// out: an incident that reaches Claude Code lists Claude Code, and one that
|
|
39
|
+
// only reaches the web app does not stop a session.
|
|
40
|
+
components: [/^Claude Code$/i, /^Claude API\b/i],
|
|
41
|
+
// Every product on this page is Claude, so its overall status is still
|
|
42
|
+
// about the sessions if the components above are ever renamed.
|
|
43
|
+
pageFallback: true,
|
|
44
|
+
},
|
|
45
|
+
codex: {
|
|
46
|
+
summaryUrl: "https://status.openai.com/api/v2/summary.json",
|
|
47
|
+
pageUrl: "https://status.openai.com",
|
|
48
|
+
// Codex Web and Codex API by name; the CLI and the editor extension are
|
|
49
|
+
// listed on that page without the word.
|
|
50
|
+
components: [/\bCodex\b/i, /^CLI$/i, /^VS Code extension$/i],
|
|
51
|
+
// Not this page's overall status: it is mostly ChatGPT, and a ChatGPT
|
|
52
|
+
// outage drawn as "Codex · partial outage" is the false alarm this module
|
|
53
|
+
// exists to avoid. Components renamed away is "unknown" here.
|
|
54
|
+
pageFallback: false,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** How long an answer is the answer. Status pages are served from a CDN that
|
|
59
|
+
* caches for about a minute, so asking more often buys nothing. A little
|
|
60
|
+
* UNDER the page's three-minute poll, and that is deliberate: an answer is
|
|
61
|
+
* stamped when it arrives, so the next poll lands a few seconds short of
|
|
62
|
+
* three minutes after it, and a cache of exactly three minutes handed every
|
|
63
|
+
* other poll the previous answer — an incident took six minutes to show. */
|
|
64
|
+
export const FRESH_MS = 150_000;
|
|
65
|
+
|
|
66
|
+
/** How long a failed read waits before the next one may go out, so a page
|
|
67
|
+
* that asks on every poll cannot turn an offline machine into a retry loop. */
|
|
68
|
+
export const RETRY_MS = 60_000;
|
|
69
|
+
|
|
70
|
+
/** How old the last answer may be before it is no answer at all. Past this a
|
|
71
|
+
* stale incident stops being drawn: it may well be over, and "Claude · major
|
|
72
|
+
* outage" from an hour ago is not a fact about now. */
|
|
73
|
+
export const EXPIRE_MS = 30 * 60_000;
|
|
74
|
+
|
|
75
|
+
const FETCH_TIMEOUT_MS = 8_000;
|
|
76
|
+
// A summary is 2-6 KB. Anything a thousand times that is not a status page.
|
|
77
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
78
|
+
// Page-supplied words go on screen; they are cut, never trusted to be short.
|
|
79
|
+
const MAX_WORDS = 160;
|
|
80
|
+
|
|
81
|
+
/** A component's status, in this deck's words. */
|
|
82
|
+
const COMPONENT_STATES = {
|
|
83
|
+
operational: "operational",
|
|
84
|
+
degraded_performance: "degraded",
|
|
85
|
+
partial_outage: "partial_outage",
|
|
86
|
+
major_outage: "major_outage",
|
|
87
|
+
under_maintenance: "maintenance",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The page-wide indicator, in the same words — the fallback only. */
|
|
91
|
+
const PAGE_INDICATORS = {
|
|
92
|
+
none: "operational",
|
|
93
|
+
minor: "degraded",
|
|
94
|
+
major: "partial_outage",
|
|
95
|
+
critical: "major_outage",
|
|
96
|
+
maintenance: "maintenance",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Worst last. Maintenance is below degraded: it was planned, and it said so. */
|
|
100
|
+
const SEVERITY = ["operational", "maintenance", "degraded", "partial_outage", "major_outage"];
|
|
101
|
+
|
|
102
|
+
/** The incident and maintenance statuses that are over. */
|
|
103
|
+
const FINISHED = new Set(["resolved", "postmortem", "completed", "scheduled"]);
|
|
104
|
+
|
|
105
|
+
const own = (map, key) => typeof key === "string" && Object.hasOwn(map, key);
|
|
106
|
+
const words = (s) => (typeof s === "string" && s.trim() ? s.trim().replace(/\s+/g, " ").slice(0, MAX_WORDS) : null);
|
|
107
|
+
|
|
108
|
+
/** Whether this deck asks the status pages at all. AGENTS_DECK_NO_INSTALL is
|
|
109
|
+
* the switch the README says turns off everything but the quota reads, so it
|
|
110
|
+
* turns this off too. */
|
|
111
|
+
export function statusChecksOff(env = process.env) {
|
|
112
|
+
return env.AGENTS_DECK_NO_STATUS === "1" || env.AGENTS_DECK_NO_INSTALL === "1";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One provider's page, read. Null when the body is not a summary this module
|
|
117
|
+
* can read — which the caller files as a failed read, not as "operational".
|
|
118
|
+
*
|
|
119
|
+
* @returns {{ state: string, summary: string | null, components: string[], scope: "components" | "page", updatedAt: number | null } | null}
|
|
120
|
+
*/
|
|
121
|
+
export function readSummary(provider, body) {
|
|
122
|
+
const spec = PROVIDERS[provider];
|
|
123
|
+
if (!spec || !body || typeof body !== "object") return null;
|
|
124
|
+
const updatedAt = Date.parse(body.page?.updated_at) || null;
|
|
125
|
+
const listed = Array.isArray(body.components) ? body.components : [];
|
|
126
|
+
const ours = listed.filter(c =>
|
|
127
|
+
c && typeof c.name === "string" && c.group !== true && own(COMPONENT_STATES, c.status)
|
|
128
|
+
&& spec.components.some(re => re.test(c.name.trim())));
|
|
129
|
+
|
|
130
|
+
if (ours.length > 0) {
|
|
131
|
+
let state = "operational";
|
|
132
|
+
for (const c of ours) {
|
|
133
|
+
const s = COMPONENT_STATES[c.status];
|
|
134
|
+
if (SEVERITY.indexOf(s) > SEVERITY.indexOf(state)) state = s;
|
|
135
|
+
}
|
|
136
|
+
const hit = ours.filter(c => c.status !== "operational");
|
|
137
|
+
// The title is looked for among the components at the WORST state only: a
|
|
138
|
+
// maintenance window on one component is not the name of an outage on
|
|
139
|
+
// another.
|
|
140
|
+
const worst = hit.filter(c => COMPONENT_STATES[c.status] === state);
|
|
141
|
+
return {
|
|
142
|
+
state,
|
|
143
|
+
summary: state === "operational" ? null : incidentName(body, new Set(worst.map(c => c.id))),
|
|
144
|
+
// Names, deduplicated — OpenAI's page lists two components as "Login",
|
|
145
|
+
// and a page may list one twice in a group and out of it.
|
|
146
|
+
components: [...new Set(hit.map(c => words(c.name)).filter(Boolean))],
|
|
147
|
+
scope: "components",
|
|
148
|
+
updatedAt,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const indicator = body.status?.indicator;
|
|
153
|
+
if (!own(PAGE_INDICATORS, indicator)) return null;
|
|
154
|
+
// A page that answered, whose overall status is not about this provider's
|
|
155
|
+
// sessions: a reading, and the honest one is that the deck cannot say.
|
|
156
|
+
if (!spec.pageFallback) {
|
|
157
|
+
return { state: "unknown", summary: null, components: [], scope: "page", updatedAt };
|
|
158
|
+
}
|
|
159
|
+
const state = PAGE_INDICATORS[indicator];
|
|
160
|
+
return {
|
|
161
|
+
state,
|
|
162
|
+
summary: state === "operational" ? null : words(body.status.description),
|
|
163
|
+
components: [],
|
|
164
|
+
scope: "page",
|
|
165
|
+
updatedAt,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The title of the unfinished incident — or maintenance — that names one of
|
|
171
|
+
* the affected components, when the page says which.
|
|
172
|
+
*
|
|
173
|
+
* Anthropic's page lists each incident's components; OpenAI's summary carries
|
|
174
|
+
* no incidents at all, so its answer is the component names on their own.
|
|
175
|
+
* An incident that names none of ours is not borrowed: a claude.ai outage's
|
|
176
|
+
* title over a Claude Code line would be a diagnosis of the wrong thing.
|
|
177
|
+
*/
|
|
178
|
+
function incidentName(body, affected) {
|
|
179
|
+
const open = [
|
|
180
|
+
...(Array.isArray(body.incidents) ? body.incidents : []),
|
|
181
|
+
...(Array.isArray(body.scheduled_maintenances) ? body.scheduled_maintenances : []),
|
|
182
|
+
].filter(i => i && !FINISHED.has(i.status));
|
|
183
|
+
for (const i of open) {
|
|
184
|
+
const named = Array.isArray(i.components) ? i.components : [];
|
|
185
|
+
if (named.some(c => affected.has(c?.id))) return words(i.name);
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** GET a page's summary: a parsed body, or a thrown reason. */
|
|
191
|
+
async function fetchSummary(fetchImpl, url) {
|
|
192
|
+
const res = await fetchImpl(url, {
|
|
193
|
+
headers: { accept: "application/json" },
|
|
194
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
195
|
+
});
|
|
196
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
197
|
+
if (Number(res.headers?.get?.("content-length")) > MAX_BODY_BYTES) throw new Error("body too large");
|
|
198
|
+
return JSON.parse(await cappedText(res, MAX_BODY_BYTES));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The body as text, refused past `max` BYTES while it is still arriving. A
|
|
203
|
+
* page that sends no Content-Length would otherwise be read whole before its
|
|
204
|
+
* size could be checked, and `text.length` counts UTF-16 units, not bytes.
|
|
205
|
+
*/
|
|
206
|
+
export async function cappedText(res, max) {
|
|
207
|
+
const reader = res.body?.getReader?.();
|
|
208
|
+
if (!reader) {
|
|
209
|
+
const text = await res.text();
|
|
210
|
+
if (Buffer.byteLength(text, "utf8") > max) throw new Error("body too large");
|
|
211
|
+
return text;
|
|
212
|
+
}
|
|
213
|
+
const chunks = [];
|
|
214
|
+
let bytes = 0;
|
|
215
|
+
for (;;) {
|
|
216
|
+
const { done, value } = await reader.read();
|
|
217
|
+
if (done) break;
|
|
218
|
+
bytes += value.byteLength;
|
|
219
|
+
if (bytes > max) {
|
|
220
|
+
reader.cancel().catch(() => {});
|
|
221
|
+
throw new Error("body too large");
|
|
222
|
+
}
|
|
223
|
+
chunks.push(value);
|
|
224
|
+
}
|
|
225
|
+
return Buffer.concat(chunks.map(c => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString("utf8");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The cache and the reads behind GET /api/provider-status. A factory so a test
|
|
230
|
+
* can hand in its own fetch and clock; the route uses the one instance below.
|
|
231
|
+
*/
|
|
232
|
+
export function createProviderStatus({ fetchImpl = (...a) => fetch(...a), now = () => Date.now() } = {}) {
|
|
233
|
+
/** provider → { reading, checkedAt, failedAt, inflight } */
|
|
234
|
+
const held = new Map();
|
|
235
|
+
|
|
236
|
+
function slot(provider) {
|
|
237
|
+
let h = held.get(provider);
|
|
238
|
+
if (!h) held.set(provider, h = { reading: null, checkedAt: 0, failedAt: 0, inflight: null });
|
|
239
|
+
return h;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function refresh(provider, h) {
|
|
243
|
+
try {
|
|
244
|
+
const reading = readSummary(provider, await fetchSummary(fetchImpl, PROVIDERS[provider].summaryUrl));
|
|
245
|
+
if (!reading) throw new Error("not a status summary");
|
|
246
|
+
h.reading = reading;
|
|
247
|
+
h.checkedAt = now();
|
|
248
|
+
h.failedAt = 0;
|
|
249
|
+
} catch {
|
|
250
|
+
h.failedAt = now();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One provider's line, reading the page first when the answer is due. */
|
|
255
|
+
async function read(provider) {
|
|
256
|
+
const h = slot(provider);
|
|
257
|
+
const t = now();
|
|
258
|
+
const due = t - h.checkedAt >= FRESH_MS && t - h.failedAt >= RETRY_MS;
|
|
259
|
+
if (due) {
|
|
260
|
+
// Deduplicated: the page and the tray asking in the same second is one
|
|
261
|
+
// request, not two.
|
|
262
|
+
h.inflight ??= refresh(provider, h).finally(() => { h.inflight = null; });
|
|
263
|
+
await h.inflight;
|
|
264
|
+
}
|
|
265
|
+
return view(provider, h, now());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { read };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What the route says about one provider.
|
|
273
|
+
*
|
|
274
|
+
* `checkedAt` is when the page last ANSWERED, never when it was last asked, so
|
|
275
|
+
* an answer cannot look fresher than it is; `stale` says the latest ask got no
|
|
276
|
+
* answer. Past EXPIRE_MS the reading is dropped rather than shown stale.
|
|
277
|
+
*/
|
|
278
|
+
export function view(provider, h, t) {
|
|
279
|
+
const base = { provider, statusPageUrl: PROVIDERS[provider].pageUrl };
|
|
280
|
+
const r = h?.reading;
|
|
281
|
+
if (!r || t - h.checkedAt > EXPIRE_MS) {
|
|
282
|
+
return { ...base, state: "unknown", summary: null, components: [], updatedAt: null, checkedAt: null, stale: false };
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
...base,
|
|
286
|
+
state: r.state,
|
|
287
|
+
summary: r.summary,
|
|
288
|
+
components: r.components,
|
|
289
|
+
scope: r.scope,
|
|
290
|
+
updatedAt: r.updatedAt,
|
|
291
|
+
checkedAt: h.checkedAt,
|
|
292
|
+
stale: h.failedAt > h.checkedAt,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const shared = createProviderStatus();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The answer for GET /api/provider-status: one line for each CLI the deck
|
|
300
|
+
* watches, and none for the one it does not (#402) — a Codex-less deck has no
|
|
301
|
+
* reason to ask OpenAI anything.
|
|
302
|
+
*/
|
|
303
|
+
export async function providerStatusReport({ providers, env = process.env, instance = shared } = {}) {
|
|
304
|
+
if (statusChecksOff(env)) return { ok: true, disabled: true, providers: [] };
|
|
305
|
+
const asked = Object.keys(PROVIDERS).filter(p => providers?.[p] !== false);
|
|
306
|
+
return { ok: true, disabled: false, providers: await Promise.all(asked.map(p => instance.read(p))) };
|
|
307
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// The pulse line — the one line that stays on screen for hours under the boot
|
|
2
|
+
// report — and the "not registered" sentence printed above it when this deck's
|
|
3
|
+
// discovery file could not be written.
|
|
4
|
+
//
|
|
5
|
+
// Lifted out of term.mjs, whose glyph tier it takes its dash and bullet from:
|
|
6
|
+
// term.mjs keeps the rules about the terminal, and this is what the deck says
|
|
7
|
+
// on it. bin/cli/pulse.js paints the line and bin/deck.js prints the sentence,
|
|
8
|
+
// and both are decided here, where every input is a parameter and nothing
|
|
9
|
+
// writes.
|
|
10
|
+
import { glyphs } from "./term.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one line that stays on screen for hours, sized to the terminal it is on.
|
|
14
|
+
*
|
|
15
|
+
* It is redrawn over itself with `\r`, so both messages are padded to one width
|
|
16
|
+
* — the shorter has to cover the longer — and that width is clamped to the real
|
|
17
|
+
* terminal: at 40 columns the old 58-character message wrapped, after which the
|
|
18
|
+
* `\r` only ever reached the second row and every repaint left the first one
|
|
19
|
+
* behind. A deck no hook can find is not listening in any sense the user cares
|
|
20
|
+
* about, which is why that state has a message here at all.
|
|
21
|
+
*
|
|
22
|
+
* `claude` is what makes that last sentence conditional. A deck watching only
|
|
23
|
+
* Codex is not waiting on a hook: startCodexWatcher is started by startServer
|
|
24
|
+
* and never consults the discovery file, and writesCodexLog keeps a deck with no
|
|
25
|
+
* record on disk writing its log. Capture and persistence both work perfectly
|
|
26
|
+
* there, while this line used to repaint "hooks cannot find this deck" every
|
|
27
|
+
* 800ms, forever (#404) — a permanent alarm about a mechanism that deck does not
|
|
28
|
+
* use. The one-time report at boot still says what IS lost; see
|
|
29
|
+
* unregisteredDetail.
|
|
30
|
+
*/
|
|
31
|
+
export function pulseText({
|
|
32
|
+
registered = true, claude = true, columns = 80, unicode = true, indent = 2, busy = null,
|
|
33
|
+
} = {}) {
|
|
34
|
+
const g = glyphs(unicode);
|
|
35
|
+
const room = Math.max(4, columns - indent - 3 - 1);
|
|
36
|
+
const pick = (options) => options.find((o) => o.length <= room) ?? options[options.length - 1].slice(0, room);
|
|
37
|
+
|
|
38
|
+
const rest = pick([`listening ${g.dash} Ctrl+C to stop`, "listening"]);
|
|
39
|
+
const bad = pick([
|
|
40
|
+
`listening, but not registered ${g.dash} hooks cannot find this deck`,
|
|
41
|
+
`not registered ${g.dash} hooks cannot find this deck`,
|
|
42
|
+
"not registered",
|
|
43
|
+
]);
|
|
44
|
+
// Both branches are still measured, registered or not, because the line is
|
|
45
|
+
// redrawn over itself and the shorter message has to cover the longer one on
|
|
46
|
+
// the beat after a deck loses its registration.
|
|
47
|
+
//
|
|
48
|
+
// The width is deliberately computed from the two FIXED messages only. `busy`
|
|
49
|
+
// comes and goes on a single boot — an install starts, the line names it, the
|
|
50
|
+
// install ends and the line goes back to Ctrl+C — so a width that grew to fit
|
|
51
|
+
// the label would have to shrink again afterwards, and the shorter line would
|
|
52
|
+
// leave the tail of the longer one on screen. Instead the label is shown only
|
|
53
|
+
// where it already fits — 60 columns and wider, measured — and below that the
|
|
54
|
+
// line says the true thing it has always said.
|
|
55
|
+
const width = Math.min(room, Math.max(rest.length, bad.length));
|
|
56
|
+
// What is still happening, rather than what is always true. `Ctrl+C to stop`
|
|
57
|
+
// is the right thing to say to somebody with nothing left to wait for, and
|
|
58
|
+
// the wrong thing to say to somebody watching an install.
|
|
59
|
+
const label = typeof busy === "string" && busy.trim() ? busy.trim() : null;
|
|
60
|
+
const working = label ? `listening ${g.bullet} ${label}` : null;
|
|
61
|
+
const ok = working && working.length <= width ? working : rest;
|
|
62
|
+
return (registered || !claude ? ok : bad).padEnd(width);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the line has anything left to say by moving.
|
|
67
|
+
*
|
|
68
|
+
* #742. The dot alternated green and grey every 800ms for as long as the deck
|
|
69
|
+
* ran, and a blinking indicator beside a status line is the vocabulary of
|
|
70
|
+
* "working on it" — so a boot that had finished in a second read as one that
|
|
71
|
+
* never finished, and people said so. The deck's own web UI already retired
|
|
72
|
+
* this once: the pill goes quiet at rest (#720). The terminal did not.
|
|
73
|
+
*
|
|
74
|
+
* Motion is now spent on the two states where something is genuinely
|
|
75
|
+
* outstanding — a deck no hook can find, and a background job still running —
|
|
76
|
+
* and nowhere else. At rest the dot is painted once, in the healthy colour, and
|
|
77
|
+
* left alone. Movement then means something changed, which is the only thing
|
|
78
|
+
* movement should ever mean on a line somebody leaves open for hours.
|
|
79
|
+
*/
|
|
80
|
+
export function pulseMoves({ registered = true, claude = true, busy = null } = {}) {
|
|
81
|
+
if (!registered && claude) return true;
|
|
82
|
+
return typeof busy === "string" && busy.trim() !== "";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether the dot is lit on this beat.
|
|
87
|
+
*
|
|
88
|
+
* `"on"` on every beat of a deck at rest, which is what makes the line still:
|
|
89
|
+
* bin/cli/pulse.js paints a beat only when the frame differs from the one
|
|
90
|
+
* already on screen, so a dot that is always lit is a line written once and
|
|
91
|
+
* then left alone. Alternating is reserved for the states pulseMoves admits.
|
|
92
|
+
*
|
|
93
|
+
* The beat is a parameter rather than counted here so this is a function of its
|
|
94
|
+
* inputs and nothing else — and so a test can ask what the twentieth beat of an
|
|
95
|
+
* idle deck looks like without waiting sixteen seconds for it.
|
|
96
|
+
*/
|
|
97
|
+
export function pulseDot(beat, { registered = true, claude = true, busy = null } = {}) {
|
|
98
|
+
if (!pulseMoves({ registered, claude, busy })) return "on";
|
|
99
|
+
return beat % 2 === 0 ? "on" : "off";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The second line of the "not registered" report, which bin/deck.js prints once
|
|
104
|
+
* per change of state rather than every beat.
|
|
105
|
+
*
|
|
106
|
+
* What a missing discovery file costs is not the same on the two capture paths,
|
|
107
|
+
* and the old sentence — "hooks find this deck through <file>, so until that
|
|
108
|
+
* file exists no events arrive" — stated the Claude Code answer as if it were
|
|
109
|
+
* both. On a Codex-only deck it is false twice over: no hook is looking for this
|
|
110
|
+
* deck, and events do keep arriving, because the rollout watcher reads the files
|
|
111
|
+
* directly. What that deck really loses is the writer election in
|
|
112
|
+
* log-election.mjs, which is how several decks tailing one rollout agree on
|
|
113
|
+
* which of them appends it to a shared events log. A deck with no record on
|
|
114
|
+
* disk is assumed to be writing, so nothing is dropped — the log can gain the
|
|
115
|
+
* same line twice instead.
|
|
116
|
+
*
|
|
117
|
+
* @param file the discovery file that could not be written.
|
|
118
|
+
* @param claude whether this deck is watching Claude Code at all.
|
|
119
|
+
* @param dash the em dash, or the ASCII stand-in on a console that cannot draw
|
|
120
|
+
* one (#797). A parameter for the same reason `renameNotice` takes one:
|
|
121
|
+
* term.mjs says, above its glyphs, "every glyph the deck prints — punctuation
|
|
122
|
+
* included, since an em dash is as absent from CP437 as a check mark — comes
|
|
123
|
+
* from one of them", and this sentence had it hardcoded. On a legacy cmd.exe
|
|
124
|
+
* or the Linux virtual console the reader got a box in the middle of the one
|
|
125
|
+
* line explaining why no events are arriving.
|
|
126
|
+
*/
|
|
127
|
+
export function unregisteredDetail({ file, claude = true, dash = "—" }) {
|
|
128
|
+
if (claude) {
|
|
129
|
+
return `Claude Code hooks find this deck through ${file}, so until that file exists no Claude Code events arrive.`;
|
|
130
|
+
}
|
|
131
|
+
return `Codex capture does not use ${file} ${dash} the deck tails the rollout files itself ${dash} so events still arrive. Only decks sharing one events log need it, to agree on which of them records.`;
|
|
132
|
+
}
|
package/src/server/purge-key.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { readFile, rm } from "node:fs/promises";
|
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
import { deckDataDir, legacyDeckDir } from "./deck-home.mjs";
|
|
35
|
+
import { stripBom } from "./atomic-write.mjs";
|
|
35
36
|
|
|
36
37
|
/** The file. Named once, because it is spelled in deck-prefs.mjs too and the
|
|
37
38
|
* two must not drift — see `prefsPath`, which is the same join. */
|
|
@@ -88,13 +89,12 @@ export async function findKeyFiles(dirs = keyDirs(), deps = {}) {
|
|
|
88
89
|
continue;
|
|
89
90
|
}
|
|
90
91
|
try {
|
|
91
|
-
// The BOM, stripped the
|
|
92
|
-
// `JSON.parse` throws on one and
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
92
|
+
// The BOM, stripped by the rule settings.json, prefs.json and the
|
|
93
|
+
// browser-watch state are all read with: `JSON.parse` throws on one and
|
|
94
|
+
// a file Notepad saved is not damaged. This was a copy of it, spelled out
|
|
95
|
+
// while stripBom was private; it is exported now (atomic-write.mjs), so
|
|
96
|
+
// the rule has one spelling.
|
|
97
|
+
const parsed = JSON.parse(stripBom(String(raw)));
|
|
98
98
|
const secret = parsed?.lan?.secret;
|
|
99
99
|
out.push({ path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" });
|
|
100
100
|
} catch (err) {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Source 3 of quota.mjs's chain: `claude --print /usage`, and the `claude` it
|
|
2
|
+
// runs.
|
|
3
|
+
//
|
|
4
|
+
// The most expensive of the three sources — a whole Claude Code process per
|
|
5
|
+
// attempt — and the only self-service one on a Mac, where the token is in the
|
|
6
|
+
// Keychain and there is no credentials file to read. What lives here is the
|
|
7
|
+
// part that is about the CLI itself: which binary to run, one run of it, and
|
|
8
|
+
// the one line said when that run fails. How many attempts, how far apart, and
|
|
9
|
+
// what to publish when none of them prints a window stay in quota.mjs, beside
|
|
10
|
+
// the floors and the cache those answers are decided against.
|
|
11
|
+
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
12
|
+
import { pathLookup, run } from "./exec.mjs";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { PRODUCT } from "./brand.mjs";
|
|
16
|
+
import { stripAnsi } from "./term.mjs";
|
|
17
|
+
import { parseUsageText } from "./quota-shape.mjs";
|
|
18
|
+
|
|
19
|
+
// Where the `claude` CLI can be. The list moved to claude-dir.mjs, which is the
|
|
20
|
+
// module that owns every "where does Claude Code live" answer the deck has —
|
|
21
|
+
// the config dir was already there, and the boot-time presence check that reads
|
|
22
|
+
// this same list had no business importing a quota poller to get at it.
|
|
23
|
+
|
|
24
|
+
/** Which `claude` to run for `--print /usage`: the first candidate that exists.
|
|
25
|
+
*
|
|
26
|
+
* This used to hand back a whole shell command line — `"<bin>" --print /usage
|
|
27
|
+
* < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
|
|
28
|
+
* POSIX: `$(…)`, backticks and `\` all still work inside them, and every
|
|
29
|
+
* ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
|
|
30
|
+
* so a home directory named `/home/a$(id)b` was shell code the quota poll ran
|
|
31
|
+
* every minute. A bare `$` was the duller half of the same bug — it expanded
|
|
32
|
+
* to nothing and the probe looked for a binary at a path that did not exist.
|
|
33
|
+
*
|
|
34
|
+
* There is nothing left to escape once there is no shell: exec.mjs's `run`
|
|
35
|
+
* spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
|
|
36
|
+
* spelling itself, and closes the child's stdin — which is what `< /dev/null`
|
|
37
|
+
* was for, since `claude --print` waits three seconds on a stdin pipe nobody
|
|
38
|
+
* is writing to.
|
|
39
|
+
*
|
|
40
|
+
* Exported, with everything it touches injectable, so the Windows branch is
|
|
41
|
+
* testable from the platforms this repo is actually developed on.
|
|
42
|
+
*
|
|
43
|
+
* WHY THE BARE NAME HAS TO EARN ITS PLACE (#553). This used to be a `.find`
|
|
44
|
+
* over `!c.includes(sep) || exists(c)`, which reads as "a bare name always
|
|
45
|
+
* answers, a full path only when it is there". On Windows that is harmless —
|
|
46
|
+
* the bare name is LAST in the list — but on POSIX it is FIRST, so the `||`
|
|
47
|
+
* short-circuited on candidate one and `exists` was never called even once:
|
|
48
|
+
* `~/.local/bin/claude`, `/usr/local/bin/claude` and `/opt/homebrew/bin/claude`
|
|
49
|
+
* were in a list nothing ever read. The user this broke is the one
|
|
50
|
+
* claude-dir.mjs names out loud: Claude Code installed by the official
|
|
51
|
+
* installer, so the binary is at `~/.local/bin/claude`, and the deck launched
|
|
52
|
+
* from something whose PATH never sourced a shell rc — a LaunchAgent, a
|
|
53
|
+
* systemd user unit, pm2, a desktop shortcut. `hasClaudeInstalled()` stats the
|
|
54
|
+
* absolute paths and says yes, so hooks install and the Claude surface turns
|
|
55
|
+
* on; every `claude --print /usage` spawn is then a bare-name ENOENT logged as
|
|
56
|
+
* `quota: claude CLI failed`. On macOS there is no `.credentials.json` to fall
|
|
57
|
+
* back to (the token is in the Keychain, #360), so the quota panel simply stays
|
|
58
|
+
* dark on a machine that plainly has Claude Code. The identical install on
|
|
59
|
+
* Windows worked, because there the ordering already said what this now says.
|
|
60
|
+
*
|
|
61
|
+
* WHICH WINS. The candidate list's own order decides, unchanged on both
|
|
62
|
+
* platforms — PATH first on POSIX, the two known install directories first on
|
|
63
|
+
* Windows — because the ordering question here is the one getRunner in
|
|
64
|
+
* ccusage.mjs already answered: preferring a different copy would silently
|
|
65
|
+
* change which binary runs on every machine that has two, and a deck that
|
|
66
|
+
* works today must not start running a `claude` it has never run. A user with
|
|
67
|
+
* a current claude on PATH via nvm/mise/volta and a stale one left in
|
|
68
|
+
* `~/.local/bin` keeps getting the one their own shell gives them. All that
|
|
69
|
+
* changes is that a bare name is now only ANSWERED WITH when PATH actually
|
|
70
|
+
* holds it, which is the same rule claudeCliOnDisk in claude-dir.mjs has
|
|
71
|
+
* always applied to this very list — the two readers of one list can no longer
|
|
72
|
+
* disagree about whether the deck can run what it says is installed.
|
|
73
|
+
*
|
|
74
|
+
* WHAT IT COSTS. One PATH walk, stopping at the first hit, and only for the
|
|
75
|
+
* bare candidate; the absolute paths are stat'ed only once PATH has come up
|
|
76
|
+
* empty. That is the trade ccusage.mjs already priced for the same shape of
|
|
77
|
+
* question — "a handful of stats, once per uncached fetch, against a process
|
|
78
|
+
* spawn that follows it" — and here the spawn that follows is a whole Claude
|
|
79
|
+
* Code process measured at ~3s, behind the SELF_POLL_MS floor.
|
|
80
|
+
*
|
|
81
|
+
* `pathLookup` is used as a yes/no gate rather than for the path it found, on
|
|
82
|
+
* purpose: answering with the bare name keeps spawn's own resolution (and, on
|
|
83
|
+
* Windows, exec.mjs's PATHEXT candidate walk) in charge of the PATH case
|
|
84
|
+
* exactly as before, so a PATH entry that merely LOOKS like a hit — a
|
|
85
|
+
* directory named `claude` — cannot become the answer.
|
|
86
|
+
*/
|
|
87
|
+
export function quotaClaudeBin(platform = process.platform, env = process.env,
|
|
88
|
+
home = homedir(), exists = existsSync) {
|
|
89
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
90
|
+
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
91
|
+
// test is not, and %Path% is how the variable is actually spelled there.
|
|
92
|
+
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
93
|
+
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
94
|
+
// A full path is worth a single stat; a bare name means "ask PATH", which
|
|
95
|
+
// is pathLookup's walk — PATHEXT included, since `claude` on Windows is
|
|
96
|
+
// spelled `claude.exe` or `claude.cmd` and never the bare word.
|
|
97
|
+
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
98
|
+
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
99
|
+
}
|
|
100
|
+
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
101
|
+
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
102
|
+
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
103
|
+
// produces is what `quota: claude CLI failed` reports.
|
|
104
|
+
return "claude";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The failure this last said out loud, so a standing one is said once.
|
|
109
|
+
*
|
|
110
|
+
* #742. A Windows user with no Claude Code installed sent a screenshot of three
|
|
111
|
+
* identical lines — `ccdeck quota: claude CLI failed: claude exited ENOENT` —
|
|
112
|
+
* interleaved with the deck's pulse line, and they keep coming for as long as
|
|
113
|
+
* the deck runs. Every poll ran the retry loop in quota.mjs's _doFetch three
|
|
114
|
+
* times, and every attempt printed. A CLI that is not installed is not news
|
|
115
|
+
* three times a minute; it is a condition, and a condition is worth exactly
|
|
116
|
+
* one line.
|
|
117
|
+
*
|
|
118
|
+
* Cleared on the first run that works, so a `claude` installed while the deck
|
|
119
|
+
* is up can still report its next genuine failure.
|
|
120
|
+
*/
|
|
121
|
+
let _saidFailure = null;
|
|
122
|
+
|
|
123
|
+
/** Exported for its test, and for the same reason resetCswapBin is: a module
|
|
124
|
+
* that remembers something across calls needs a way to be asked twice.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately NOT folded into invalidateQuotaCache, which production calls
|
|
127
|
+
* after an account switch — forgetting the notice there would put the same
|
|
128
|
+
* sentence back on the terminal every time somebody changed accounts. */
|
|
129
|
+
export function forgetQuotaFailureNotice() { _saidFailure = null; }
|
|
130
|
+
|
|
131
|
+
// Run `claude --print /usage` once. Returns { cliOk, ran, missing, parsed }.
|
|
132
|
+
// cliOk — we recognized its output (preamble present), whatever the exit
|
|
133
|
+
// ran — the run itself succeeded, which cliOk does not say
|
|
134
|
+
// missing — there is no `claude` to run at all
|
|
135
|
+
// parsed — quota percentages object, or null if the "Current session/week"
|
|
136
|
+
// lines were absent (CLI cold-start, or genuinely <1% usage)
|
|
137
|
+
export async function runUsageOnce(bin) {
|
|
138
|
+
const r = await run(bin, ["--print", "/usage"], {
|
|
139
|
+
timeout: 15_000,
|
|
140
|
+
maxBuffer: 1024 * 1024,
|
|
141
|
+
// Marks this Claude Code run as the deck's own. `claude --print /usage`
|
|
142
|
+
// is a full invocation, so it fires the hooks we installed, and every
|
|
143
|
+
// quota poll was drawing itself onto the canvas as a fresh session with
|
|
144
|
+
// no prompt and no tools. Hooks inherit the environment, so hook.js
|
|
145
|
+
// sees this and stays quiet.
|
|
146
|
+
env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
|
|
147
|
+
});
|
|
148
|
+
// `run` never rejects, so there is one path rather than two — and the output
|
|
149
|
+
// is kept either way, which matters because the CLI writes the quota lines to
|
|
150
|
+
// stdout and can still exit non-zero afterwards.
|
|
151
|
+
const combined = r.stdout + "\n" + r.stderr;
|
|
152
|
+
// `run` normalises a binary that is not there to this, on every platform —
|
|
153
|
+
// see exec.mjs. It is the difference between "Claude Code answered badly",
|
|
154
|
+
// which is worth retrying and worth saying, and "there is no Claude Code on
|
|
155
|
+
// this machine", which is neither.
|
|
156
|
+
const missing = r.code === "ENOENT";
|
|
157
|
+
if (!r.ok) {
|
|
158
|
+
const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
|
|
159
|
+
if (msg !== _saidFailure) {
|
|
160
|
+
_saidFailure = msg;
|
|
161
|
+
console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
_saidFailure = null;
|
|
165
|
+
}
|
|
166
|
+
const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
|
|
167
|
+
// WHETHER THE RUN ITSELF SUCCEEDED, which `cliOk` does not answer. `cliOk` is
|
|
168
|
+
// a test of the OUTPUT — it means "we recognised what came back" — and a CLI
|
|
169
|
+
// that printed its banner and then failed satisfies it. That is the right
|
|
170
|
+
// rule for the parse above (see the note there: the quota lines can be on
|
|
171
|
+
// stdout and the exit non-zero), and the wrong one for the no-numbers
|
|
172
|
+
// fallback in _doFetch, which was publishing 0% for a run that errored.
|
|
173
|
+
return { cliOk, ran: r.ok, missing, parsed: parseUsageText(combined) };
|
|
174
|
+
}
|