fix(statusline): resolve session id from stdin payload so per-session KPI renders (#797)

resolveSessionId() read CLAUDE_SESSION_ID (an env var Claude Code never sets)
and otherwise fell back to a /proc PID walk (`pid-<n>`), while discarding the
stdin JSON payload it parses. The recording hooks key session_events by the
Claude session UUID delivered in that payload's `session_id`, so `pid-<n>`
never matched -> sessionBytes was always 0 -> the bar always rendered the
global lifetime aggregate, identical in every session.

Read session_id from the stdin payload first, then env, then PID walk (a strict
superset of prior behavior). Widen the test harness to send a stdin payload and
add a mutation-defeating regression test for the production path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adam Clarke
2026-06-21 13:44:55 +03:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6c513fbf60
commit 6891def8a7
2 changed files with 86 additions and 6 deletions
+24 -4
View File
@@ -96,7 +96,12 @@ function readStdinJson() {
const raw = readFileSync(0, "utf-8");
if (!raw.trim()) return {};
return JSON.parse(raw);
} catch {
} catch (err) {
// The payload is load-bearing — it carries session_id, which resolves the
// per-session KPI. Empty stdin (normal first render) returned above and
// stays silent; a non-empty payload that fails to parse is a real anomaly
// worth one latched stderr line (never pollutes the statusline's stdout).
warnOnce("stdin-parse", `failed to parse statusline stdin JSON: ${err?.message ?? err}`);
return {};
}
}
@@ -187,8 +192,23 @@ function findClaudePidDarwin() {
return process.ppid;
}
function resolveSessionId() {
function resolveSessionId(payload) {
// PRIMARY: the session_id Claude Code delivers in the statusLine stdin
// payload. This is the SAME id the recording hooks key session_events by,
// so it's the only source that reliably matches stored per-session data.
//
// Claude Code does NOT export a CLAUDE_SESSION_ID env var — session_id is
// delivered only in the stdin JSON (statusline.md "Available data"). And the
// /proc PID walk yields `pid-<n>`, which never matches a UUID-keyed session.
// So without reading the payload, the per-session KPI is unreachable and the
// bar falls back to the global lifetime aggregate — identical in every
// session and seemingly "frozen".
const fromPayload = payload?.session_id;
if (typeof fromPayload === "string" && fromPayload) return fromPayload;
// Fallback when the payload carries no session_id (and how test fixtures
// pin a deterministic id). NOT an override — payload wins when present.
if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
// Last resort: walk the process tree (only matches pid-keyed events).
return `pid-${findClaudePid()}`;
}
@@ -208,9 +228,9 @@ function statusDot(pct) {
// ── Main render ──────────────────────────────────────────────────────────
async function main() {
readStdinJson(); // drain stdin even if unused, keeps Claude Code happy
const payload = readStdinJson(); // canonical source of session_id
const sessionsDir = resolveSessionDir();
const sessionId = resolveSessionId();
const sessionId = resolveSessionId(payload);
const analytics = await loadAnalytics();
+62 -2
View File
@@ -62,9 +62,9 @@ function isolatedHomeEnv(): Record<string, string> {
return buildIsolatedEnvObject().env;
}
function runStatusline(env: Record<string, string>) {
function runStatusline(env: Record<string, string>, input = "{}") {
const result = spawnSync("node", [STATUSLINE], {
input: "{}",
input,
env: { ...process.env, NO_COLOR: "1", ...isolatedHomeEnv(), ...env },
encoding: "utf-8",
});
@@ -215,6 +215,66 @@ describe("statusline.mjs — SessionDB-backed reads", () => {
assert.doesNotMatch(stdout, /NaN/);
});
// REGRESSION (#statusline-session-id): the per-session "this chat" KPI must
// resolve from the stdin payload's `session_id`. Claude Code does NOT export
// a CLAUDE_SESSION_ID env var (statusline.md "Available data" — session_id is
// delivered only in the stdin JSON), and the recording hooks key
// session_events by that same id. Reading only the env var / PID walk yields
// `pid-<n>`, which never matches → sessionBytes is always 0 → the bar shows
// only the global lifetime aggregate, identical in every session.
//
// Magnitude-based mutation-defeat: 'other' is deliberately ~60× larger than
// 'mine' (3000 vs 50 events). Two mutations turn this red:
// • reverting resolveSessionId() to ignore the payload → "this chat"
// disappears entirely (no KB match)
// • dropping the sessionId filter in getRealBytesStats → "this chat"
// absorbs 'other' and renders in MB, not KB
test("resolves per-session KPI from the stdin payload session_id (no env var)", { timeout: STATUSLINE_SQLITE_TIMEOUT_MS }, () => {
const sid = "11111111-2222-3333-4444-555555555555";
// Per-session bytes for THIS id…
const mine = Array.from({ length: 50 }, () => ({
sessionId: sid,
bytesAvoided: 1024,
data: "x".repeat(64),
}));
// …plus an unrelated session so lifetime > 0 regardless of the active id.
// Deliberately ~60× larger than 'mine' to make the magnitude check
// mutation-defeating: if the sessionId filter is dropped, "this chat"
// absorbs the combined total and renders in MB instead of KB.
const other = Array.from({ length: 3000 }, () => ({
sessionId: "99999999-aaaa-bbbb-cccc-dddddddddddd",
bytesAvoided: 1024,
data: "y".repeat(64),
}));
seedSessionDb({ dir, events: [...mine, ...other] });
// Production path: session_id arrives ONLY on stdin. CLAUDE_SESSION_ID is
// explicitly empty so the env branch cannot mask a broken payload read.
const { stdout } = runStatusline(
{ CONTEXT_MODE_DIR: root, CLAUDE_SESSION_ID: "" },
JSON.stringify({ session_id: sid }),
);
assert.match(stdout, /context-mode/, "brand visible");
// The active session (mine, 50 events ≈ tens of KB) is ~60× smaller than
// the unrelated 'other' session (3000 events ≈ MB). So a correctly
// session-scoped "this chat" renders in KB. Two mutations turn this red:
// • reverting resolveSessionId to ignore the payload → no "this chat" at all
// • dropping the sessionId filter in getRealBytesStats → "this chat" absorbs
// 'other' and renders in MB
assert.match(
stdout,
/\d+(\.\d+)?\s*KB\s+this chat/,
"active-session KPI present and scoped to the small active session (KB)",
);
assert.doesNotMatch(
stdout,
/\bMB\s+this chat/,
"'this chat' must not include the large unrelated session's bytes",
);
assert.doesNotMatch(stdout, /NaN/);
});
// SLICE 1 cont: no SessionDB → headline fallback (substantiated, no $).
test("empty sessionsDir falls back to substantiated headline", () => {
// dir exists but has no .db files