Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Stale-cache fallback removes each expired window's usage and reset fields while
- **Transcript-backed widgets** stream the active JSONL transcript once per render through `getTranscriptAnalysis()`, collecting token, duration, speed, compaction, thinking-effort, and session-name data in one pass without materializing the whole file. Referenced subagent transcripts are streamed separately only when speed metrics include subagents. Cumulative token totals deduplicate nonempty `message.id` values across the transcript, retaining the first counted input/cache snapshot and the maximum output count; records without IDs count independently. Speed collectors also deduplicate response IDs per transcript, use the latest input count and maximum output count, and keep separate active intervals for interleaved records so idle gaps do not become processing time. Session names prefer the latest valid `custom-title`, falling back to the latest valid `ai-title`.
- **Block Timer** caches a detected block until its five-hour window expires. When a full scan finds no active block, that empty result is cached for one minute so subsequent repaints do not repeatedly walk and read the entire transcript history. The transcript fallback scans relative to the config directory to preserve literal path characters, excludes sidechain and API-error rows, and extends its lookback when a gap may be filled by older transcripts. Future block starts and empty-cache deadlines more than one minute ahead are rejected after clock changes.
- **Cache Timer** prefers usable `prompt_cache` state from stdin: `expires_at` is a Unix timestamp in seconds, `warm: false` means cold, and `ttl` (`5m` or `1h`) controls glyph thresholds. `caching_observed: false` or missing usable state falls back to the transcript tail; only this fallback shows the working state and estimates expiry using the configured TTL. It expands the read backward when a trailing JSONL record exceeds the initial window, ignores sidechain and synthetic API-error rows, and anchors the countdown only on assistant requests with cache activity. Interrupt markers and local slash-command records end the working state without refreshing the anchor. It does not create a separate cache file.
- **Claude Status** reads `status.claude.com` through HTTPS, honors `HTTPS_PROXY` unless `NO_PROXY` excludes the host, and caches successful responses for five minutes. It requests incident data only when at least one configured Claude Status widget enables history, applies a 30-second backoff after failed fetches, and serves a usable stale cache when available.
- **Claude Status** reads `status.claude.com` through HTTPS, honors `HTTPS_PROXY` unless `NO_PROXY` excludes the host, and caches successful responses for five minutes. It requests incident data only when at least one configured Claude Status widget enables history, applies a 30-second backoff after failed fetches, and serves a usable stale cache while that cache is under an hour old.
- **Local Git widgets** cache command results in memory and under `~/.cache/ccstatusline/git-cache`, with one persistent file per repository/working-directory pair. A conservative filesystem check skips Git when the directory is definitely outside a repository, while explicit Git environment overrides still allow invocation. Cache misses invoke Git with a five-second timeout; failures, including timeouts, are cached as `null`. Cache TTL starts at command completion so slow calls still receive the full reuse interval. Persistent writes use one stable `.tmp` path per cache file and attempt best-effort cleanup on failure, bounding orphaned files when Windows virus scanners or sync clients temporarily hold a handle.
- **JJ widgets** invoke `runJjArgs()` with a five-second timeout and use their normal missing-data behavior when the command fails or times out.
- **Git PR and Git CI Status** render from the versioned disk cache under `~/.cache/ccstatusline/git-review`. Missing or stale entries are refreshed in a detached helper so network-bound `gh` or `glab` calls do not block rendering. Git CI Status adds GitHub's `statusCheckRollup`; if the authenticated `gh` token cannot read checks, the refresh retries with PR metadata only so Git PR still works.
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ ccstatusline --version

- **Model** / **Output Style** / **Version** - Show the active Claude model, output style, and Claude Code CLI version. Model names omit trailing context suffixes like `(1M context)`; use **Context Window** when you want the total window size shown.
- **Claude Session ID** / **Session Name** / **Claude Account Email** - Show session identifiers plus the currently signed-in Claude account email. Session Name uses the latest `/rename` title, falling back to the latest automatic Claude Code title when the session has not been renamed. Claude Account Email names the account the usage credentials belong to, looked up from `api.anthropic.com` once per login; see [Usage Credentials and Cache](#usage-credentials-and-cache).
- **Claude Status** - Show the current Claude/Anthropic service status from `status.claude.com` (`ok` when the indicator is `none`, otherwise the indicator word such as `minor`, `major`, `critical`, or `maintenance`). Press `h` in the editor to add a 48-hour incident-history strip of eight six-hour blocks (oldest to newest), each colored by the worst incident overlapping that block: green (none), yellow (minor), orange (major), red (critical). Responses are cached for about five minutes; on network failure the widget shows stale data when available or degrades to `?`. With the history strip enabled the widget colors itself by severity, so per-widget foreground colors and theme foregrounds are skipped, like Custom Command's preserve-colors mode. Press `l` to make the widget a link to the status page (`https://status.claude.com`), in terminals that support OSC 8 hyperlinks.
- **Claude Status** - Show the current Claude/Anthropic service status from `status.claude.com` (`ok` when the indicator is `none`, otherwise the indicator word such as `minor`, `major`, `critical`, or `maintenance`). Press `h` in the editor to add a 48-hour incident-history strip of eight six-hour blocks (oldest to newest), each colored by the worst incident overlapping that block: green (none), yellow (minor), orange (major), red (critical). Responses are cached for about five minutes; on network failure the widget shows the last fetched status while it is under an hour old, and `?` otherwise. With the history strip enabled the widget colors itself by severity, so per-widget foreground colors and theme foregrounds are skipped, like Custom Command's preserve-colors mode. Press `l` to make the widget a link to the status page (`https://status.claude.com`), in terminals that support OSC 8 hyperlinks.
- **Voice Status** - Show whether Claude Code voice input is enabled. It can render as an icon, icon plus text, plain text, or `voice on/off`, with optional Nerd Font microphone icons.
- **Fast Mode** - Show whether Claude Code's fast mode is on, from the status line's `fast_mode` field. It can render as icon plus text (`⚡ on` / `⚡ off`, the default), an icon with a state dot, plain text, or `fast on/off`, with optional Nerd Font lightning icons. It can hide while fast mode is off, and shows nothing on Claude Code versions that don't report fast mode.
- **Sandbox Status** - Show the effective `sandbox.enabled` value from Claude Code's layered project and user settings. It can render as a glyph, `SB: ON/OFF`, or `Sandbox: ON/OFF`, with optional Nerd Font lock icons. The value is refreshed after `/sandbox` changes, but is best effort when managed or CLI settings override files or sandbox initialization fails.
Expand Down
296 changes: 296 additions & 0 deletions src/utils/__tests__/claude-service-status-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import type * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import { createRequire } from 'node:module';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

import type { WidgetItem } from '../../types/Widget';

const require = createRequire(import.meta.url);
const { execFileSync: realExecFileSync } = require('node:child_process') as { execFileSync: typeof childProcess.execFileSync };

const SECOND_MS = 1000;
const MINUTE_MS = 60 * SECOND_MS;
const HOUR_MS = 60 * MINUTE_MS;
const NOW = Date.parse('2026-08-15T12:00:00Z');

const STATUS_PATH = '/api/v2/status.json';
const INCIDENTS_PATH = '/api/v2/incidents.json';
const MAJOR_STATUS_BODY = '{"status":{"indicator":"major"}}';

const statusLine: WidgetItem[][] = [[{ id: '1', type: 'claude-status' }]];
const historyLine: WidgetItem[][] = [[{ id: '1', type: 'claude-status', metadata: { history: 'true' } }]];

interface ProbeOptions {
lines?: WidgetItem[][];
// An empty body makes that request fail, as when the page can't be reached
statusBody?: string;
incidentsBody?: string;
}

interface ProbeResult {
result: Record<string, unknown> | null;
requests: string[];
homedir: string;
}

// The module resolves its cache paths from the home directory when it loads,
// so each probe imports it fresh in a child process with a scratch HOME.
function createProbeHarness() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-status-test-'));
const home = path.join(tempRoot, 'home');
const cacheDir = path.join(home, '.cache', 'ccstatusline');
const probeScriptPath = path.join(tempRoot, 'probe-claude-status.mjs');
const modulePath = fileURLToPath(new URL('../claude-service-status.ts', import.meta.url));

const probeScript = `
import * as os from 'node:os';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const https = require('node:https');
const bodies = {
${JSON.stringify(STATUS_PATH)}: process.env.TEST_STATUS_BODY || '',
${JSON.stringify(INCIDENTS_PATH)}: process.env.TEST_INCIDENTS_BODY || ''
};
const requests = [];

https.request = (options, onResponse) => {
requests.push(options.path);
const body = bodies[options.path] || '';
const requestHandlers = new Map();
const request = {
on(event, handler) {
requestHandlers.set(event, handler);
return request;
},
destroy() {},
end() {
if (!body) {
requestHandlers.get('error')?.(new Error('connect ECONNREFUSED'));
return;
}
const responseHandlers = new Map();
const response = {
statusCode: 200,
setEncoding() {},
on(event, handler) {
responseHandlers.set(event, handler);
return response;
}
};
onResponse(response);
responseHandlers.get('data')?.(body);
responseHandlers.get('end')?.();
}
};
return request;
};

const nowMs = Number(process.env.TEST_NOW_MS);
Date.now = () => nowMs;

const { prefetchClaudeStatusIfNeeded } = await import(${JSON.stringify(modulePath)});
const result = await prefetchClaudeStatusIfNeeded(JSON.parse(process.env.TEST_LINES_JSON));
process.stdout.write(JSON.stringify({ result, requests, homedir: os.homedir() }));
`;

fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(probeScriptPath, probeScript);

function seedCache(contents: Record<string, unknown>): void {
fs.writeFileSync(path.join(cacheDir, 'claude-status.json'), JSON.stringify(contents));
}

function seedFailureLock(mtimeMs: number): void {
const lockFile = path.join(cacheDir, 'claude-status.lock');
fs.writeFileSync(lockFile, '');
fs.utimesSync(lockFile, mtimeMs / 1000, mtimeMs / 1000);
}

// Another process's claim on the fetch, as one left by a process that
// stopped mid-fetch
function seedFetchClaim(mtimeMs: number): void {
const claimDir = path.join(cacheDir, 'claude-status.inflight');
const owner = path.join(claimDir, 'other-process');
fs.mkdirSync(claimDir);
fs.writeFileSync(owner, '');
fs.utimesSync(owner, mtimeMs / 1000, mtimeMs / 1000);
}

function runProbe(options: ProbeOptions = {}): ProbeResult {
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => {
const normalizedKey = key.toUpperCase();
return normalizedKey !== 'HTTPS_PROXY' && normalizedKey !== 'NO_PROXY';
}));

Object.assign(env, {
HOME: home,
// os.homedir() prefers USERPROFILE on Windows; inheriting the
// real one lets the probe escape into the user's actual home
USERPROFILE: home,
TEST_LINES_JSON: JSON.stringify(options.lines ?? statusLine),
TEST_NOW_MS: String(NOW),
TEST_STATUS_BODY: options.statusBody ?? '',
TEST_INCIDENTS_BODY: options.incidentsBody ?? ''
});

const output = realExecFileSync(process.execPath, [probeScriptPath], {
encoding: 'utf8',
env
});
const result = JSON.parse(output) as ProbeResult;

// A probe resolving a different home has escaped its sandbox and would
// read or write the real user's ~/.cache/ccstatusline
expect(result.homedir).toBe(home);

return result;
}

function cleanup(): void {
fs.rmSync(tempRoot, { recursive: true, force: true });
}

return {
cleanup,
runProbe,
seedCache,
seedFailureLock,
seedFetchClaim
};
}

type ProbeHarness = ReturnType<typeof createProbeHarness>;

function cachedStatus(fetchedAt: number, indicator: string): Record<string, unknown> {
return {
fetchedAt,
indicator,
incidents: null,
incidentsQueried: false
};
}

describe('Claude Status cache', () => {
let harness: ProbeHarness;

beforeEach(() => {
harness = createProbeHarness();
});

afterEach(() => {
harness.cleanup();
});

it('serves a cache inside its five-minute lifetime without fetching', () => {
harness.seedCache(cachedStatus(NOW - 2 * MINUTE_MS, 'none'));

const probe = harness.runProbe({ statusBody: MAJOR_STATUS_BODY });

expect(probe.requests).toEqual([]);
expect(probe.result).toEqual({ indicator: 'none' });
});

// Two hours ahead: written while the system clock ran fast, since
// corrected. A few seconds ahead: clocks a little apart.
it.each([
['two hours', 2 * HOUR_MS, [STATUS_PATH], 'major'],
['a few seconds', 2 * SECOND_MS, [], 'none']
])('refetches a cache dated %s in the future only when the date cannot be right', (_label, aheadMs, expectedRequests, expectedIndicator) => {
harness.seedCache(cachedStatus(NOW + aheadMs, 'none'));

const probe = harness.runProbe({ statusBody: MAJOR_STATUS_BODY });

expect(probe.requests).toEqual(expectedRequests);
expect(probe.result).toEqual({ indicator: expectedIndicator });
});

it.each([
['ten seconds old', -10 * SECOND_MS, [], 'none'],
['dated a few seconds in the future', 2 * SECOND_MS, [], 'none'],
['dated two hours in the future', 2 * HOUR_MS, [STATUS_PATH], 'major']
])('backs off for a failure lock %s only while it can be current', (_label, lockOffsetMs, expectedRequests, expectedIndicator) => {
harness.seedCache(cachedStatus(NOW - 10 * MINUTE_MS, 'none'));
harness.seedFailureLock(NOW + lockOffsetMs);

const probe = harness.runProbe({ statusBody: MAJOR_STATUS_BODY });

expect(probe.requests).toEqual(expectedRequests);
expect(probe.result).toEqual({ indicator: expectedIndicator });
});

it.each([
['ten seconds old', -10 * SECOND_MS, [], 'none'],
['dated a few seconds in the future', 2 * SECOND_MS, [], 'none'],
['dated two hours in the future', 2 * HOUR_MS, [STATUS_PATH], 'major']
])('leaves the fetch to another process whose claim is %s only while it can be current', (_label, claimOffsetMs, expectedRequests, expectedIndicator) => {
harness.seedCache(cachedStatus(NOW - 10 * MINUTE_MS, 'none'));
harness.seedFetchClaim(NOW + claimOffsetMs);

const probe = harness.runProbe({ statusBody: MAJOR_STATUS_BODY });

expect(probe.requests).toEqual(expectedRequests);
expect(probe.result).toEqual({ indicator: expectedIndicator });
});

describe('while the status page is unreachable', () => {
it('keeps serving the last status for up to an hour', () => {
harness.seedCache(cachedStatus(NOW - 50 * MINUTE_MS, 'major'));

const probe = harness.runProbe();

expect(probe.requests).toEqual([STATUS_PATH]);
expect(probe.result).toEqual({ indicator: 'major' });
});

it.each([
['the fetch fails', null, [STATUS_PATH]],
['the failure backoff holds off fetching', NOW - 10 * SECOND_MS, []]
])('reports the status unknown once the last one is over an hour old and %s', (_label, lockMtimeMs, expectedRequests) => {
harness.seedCache(cachedStatus(NOW - 2 * HOUR_MS, 'major'));
if (lockMtimeMs !== null) {
harness.seedFailureLock(lockMtimeMs);
}

const probe = harness.runProbe();

expect(probe.requests).toEqual(expectedRequests);
expect(probe.result).toEqual({ error: true });
});

// Written while the system clock ran fast, since corrected: its age
// can't be known, so it isn't served as the last status either
it('reports the status unknown when the last one is dated hours in the future', () => {
harness.seedCache(cachedStatus(NOW + 2 * HOUR_MS, 'none'));

const probe = harness.runProbe();

expect(probe.requests).toEqual([STATUS_PATH]);
expect(probe.result).toEqual({ error: true });
});

it('stops an incident still open in a two-day-old cache from filling the history strip', () => {
const fetchedAt = NOW - 48 * HOUR_MS;
harness.seedCache({
fetchedAt,
indicator: 'major',
incidents: [{ impact: 'critical', startMs: fetchedAt - HOUR_MS, endMs: null }],
incidentsQueried: true
});

const probe = harness.runProbe({ lines: historyLine });

expect(probe.requests).toEqual([STATUS_PATH, INCIDENTS_PATH]);
expect(probe.result).toEqual({ error: true });
});
});
});
Loading
Loading