mirror of
https://github.com/mksglu/context-mode.git
synced 2026-10-02 04:14:38 +08:00
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:
co-authored by
Claude Opus 4.8
parent
6c513fbf60
commit
6891def8a7
+24
-4
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user