ccdeck 3.30.0 → 3.32.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.
@@ -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
+ }
@@ -1,5 +1,5 @@
1
- // The usage panel's reads: Claude's quota windows, Codex's quota and usage, and
2
- // ccusage's daily history.
1
+ // The usage panel's reads: Claude's quota windows, Codex's quota and usage,
2
+ // ccusage's daily history, and what the providers' status pages say (#1311).
3
3
  //
4
4
  // These lived in src/server/index.mjs after the shutdown route, and each is the
5
5
  // same few lines: read `?refresh=1`, import the module that does the work,
@@ -10,6 +10,7 @@
10
10
  // where a string somebody else chose enters on its way to a child's argv.
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
+ import { deckProviders } from "./deck-scope.mjs";
13
14
  import { send } from "./http-io.mjs";
14
15
 
15
16
  // Resolved the way pinned-build.mjs resolves it, from a file in the same
@@ -54,6 +55,20 @@ export async function handleCodexQuota(req, res) {
54
55
  send(res, 200, quota);
55
56
  }
56
57
 
58
+ /**
59
+ * One line per watched CLI: whether its provider's status page reports an
60
+ * incident. Never a 500 for an unreachable page — provider-status.mjs files
61
+ * that as "unknown" — and no `?refresh=1`: the page's own cadence is the only
62
+ * one there is, so a button held down cannot turn into a poll of someone
63
+ * else's server.
64
+ */
65
+ export async function handleProviderStatus(req, res) {
66
+ const { providerStatusReport } = await import(
67
+ pathToFileURL(join(PKG_ROOT, "src/server/provider-status.mjs")).href
68
+ );
69
+ send(res, 200, await providerStatusReport({ providers: deckProviders() }));
70
+ }
71
+
57
72
  /**
58
73
  * ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
59
74
  * at all — an absent parameter lets ccusage.mjs pick the default 30-day range.