refactor(statusline): read from SessionDB instead of per-PID sidecar JSON

The statusline was reading per-PID stats-pid-*.json sidecars written by
persistStats() (src/server.ts:546). Three problems:
  1. Eventually-consistent — 500ms+30s persist throttle made the
     statusline lag the actual session state.
  2. PID-scoped — multiple Claude sessions sharing a shell ppid collided
     on the same sidecar.
  3. Missing the multi-adapter aggregation that ctx_stats just gained
     (B3a/B3b commits) so cross-platform users couldn't see it.

Refactor the statusline to read directly from SessionDB
(session_events + session_resume) via the same getRealBytesStats() and
getMultiAdapterLifetimeStats() calls ctx_stats uses (src/server.ts:2807-
2891). The two surfaces now share one source of truth.

What's preserved unchanged:
  - Cross-OS PID resolver (linux /proc, darwin ps, win32 fallback) at
    bin/statusline.mjs:105-200 — that's the session-id source still.
  - CONTEXT_MODE_SESSION_DIR env var override.
  - Substantiated headline fallback when no data exists.

New: bin/statusline.mjs imports analytics from build/session/analytics.js
lazily so a missing build degrades to the headline rather than crashing.
Multi-adapter $ surfaces as "across N tools" when the multi-adapter walk
finds 2+ real adapters (passing the isReal filter at analytics.ts:1300).

Sidecar persistStats() in src/server.ts:546 stays for now (other readers
may depend on it) — flagging for follow-up removal.

Tests: tests/statusline-sqlite.test.ts seeds a real SessionDB fixture
(no mocks of the analytics layer — that would couple to implementation)
and asserts the public output reflects DB state.
This commit is contained in:
Mert Koseoglu
2026-05-10 15:07:38 +03:00
parent e1a935ec35
commit e6864fa87b
2 changed files with 454 additions and 127 deletions
+137 -127
View File
@@ -2,9 +2,12 @@
/**
* context-mode status line — Claude Code statusLine integration.
*
* Reads the persisted stats file written by the MCP server and prints a
* single-line, value-first status string designed for enterprise dev
* surfaces (Loom demos, Slack screen shares, over-the-shoulder closes).
* Reads stats DIRECTLY from SessionDB (`session_events` + `session_resume`),
* mirroring the `ctx_stats` MCP handler at src/server.ts:2807-2891 so the
* statusline and ctx_stats never drift. The legacy per-PID sidecar JSON
* (`stats-pid-*.json`) is no longer the source of truth — sidecars were
* eventually-consistent (500ms+30s throttles) and PID-scoped (multiple
* Claude sessions colliding on the same shell ppid).
*
* Discipline (Datadog / Stripe / Vercel pattern):
* - "context-mode" full brand label, never abbreviated
@@ -13,32 +16,39 @@
* - No counts (calls / tokens / events) — only $ and % pass the
* value-per-pixel test
*
* Wire it up in ~/.claude/settings.json (path-free — uses the bundled CLI
* forwarder so users don't have to know the absolute install path):
* Wire it up in ~/.claude/settings.json:
* {
* "statusLine": {
* "type": "command",
* "command": "context-mode statusline"
* }
* }
*
* Or, if you prefer to skip the CLI shim, point directly at this file:
* "command": "node /absolute/path/to/context-mode/bin/statusline.mjs"
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { execFileSync } from "node:child_process";
// ── Schema versioning ───────────────────────────────────────────────────
// Bumped by the MCP writer (src/server.ts) when the persisted stats payload
// shape changes. Statusline reads `schemaVersion` from the payload:
// - missing → legacy v1.0.103 era, proceed with sensible defaults
// - <= KNOWN → safe to render fully
// - > KNOWN → newer writer than this reader; warn once + render what we
// still understand (graceful degrade rather than blank bar)
const KNOWN_SCHEMA_VERSION = 1;
// ── Analytics import — resolved relative to this script ─────────────────
// statusline.mjs ships in `bin/`; the compiled analytics module lives in
// `build/session/analytics.js`. Import lazily so a missing build doesn't
// crash the renderer — degrade to the substantiated headline instead.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ANALYTICS_PATH = resolve(__dirname, "..", "build", "session", "analytics.js");
let _analytics = null;
async function loadAnalytics() {
if (_analytics) return _analytics;
try {
_analytics = await import(ANALYTICS_PATH);
} catch {
_analytics = null;
}
return _analytics;
}
// Test seams — keep production behaviour identical when env vars unset.
// CTX_TEST_PLATFORM — override process.platform for cross-OS resolver tests
@@ -69,7 +79,7 @@ const yellow = (t) => ansi("33", t); // degraded dot
const red = (t) => ansi("31", t); // stale dot
const SEP = dim("·");
// ── Stats file lookup ────────────────────────────────────────────────────
// ── Stdin drain ─────────────────────────────────────────────────────────
function readStdinJson() {
try {
const raw = readFileSync(0, "utf-8");
@@ -100,7 +110,7 @@ function resolveSessionDir() {
* - win32: degraded — process.ppid only, with a one-shot stderr warning
*
* Without this walk, multiple concurrent Claude sessions all see the same
* shell ppid and collide on the fuzzy mtime fallback in findStatsFile.
* shell ppid and collide on per-PID stats lookup.
*/
function findClaudePid() {
const plat = platform();
@@ -137,7 +147,6 @@ function findClaudePidDarwin() {
let pid = process.ppid;
for (let i = 0; i < 8 && pid && pid > 1; i++) {
try {
// `ps -o ppid=,comm= -p <pid>` → " 12345 /path/to/claude"
const out = execFileSync(
"ps",
["-o", "ppid=,comm=", "-p", String(pid)],
@@ -148,7 +157,6 @@ function findClaudePidDarwin() {
if (!m) return process.ppid;
const parentPid = Number(m[1]);
const comm = m[2].trim();
// comm may be a path; check basename for claude
const base = comm.split("/").pop() || comm;
if (/claude/i.test(base)) return pid;
pid = parentPid;
@@ -164,158 +172,160 @@ function resolveSessionId() {
return `pid-${findClaudePid()}`;
}
function findStatsFile(sessionDir, sessionId) {
const direct = join(sessionDir, `stats-${sessionId}.json`);
if (existsSync(direct)) return direct;
try {
const candidates = readdirSync(sessionDir)
.filter((f) => f.startsWith("stats-") && f.endsWith(".json"))
.map((f) => {
const full = join(sessionDir, f);
try {
return { full, mtime: statSync(full).mtimeMs };
} catch {
return null;
}
})
.filter(Boolean)
.sort((a, b) => b.mtime - a.mtime);
// Only fall back to a file modified within the last 30 minutes —
// older files almost always belong to a stopped MCP server.
const fresh = candidates.find(
(c) => Date.now() - c.mtime < 30 * 60 * 1000,
);
if (fresh) return fresh.full;
} catch { /* ignore — sessionDir might not exist yet */ }
return null;
}
function loadStats(path) {
try {
const parsed = JSON.parse(readFileSync(path, "utf-8"));
if (parsed && typeof parsed === "object") {
// schemaVersion is optional — legacy v1.0.103 payloads omit it.
// Default to 0 so unknown-newer detection still has a clean compare.
const version = Number.isFinite(parsed.schemaVersion)
? parsed.schemaVersion
: 0;
if (version > KNOWN_SCHEMA_VERSION) {
try {
process.stderr.write(
`context-mode statusline: stats schemaVersion=${version} newer than known=${KNOWN_SCHEMA_VERSION}; rendering known fields only. Upgrade context-mode to suppress this warning.\n`,
);
} catch { /* ignore */ }
}
}
return parsed;
} catch {
return null;
}
}
// ── Formatters ───────────────────────────────────────────────────────────
function fmtUsd(n) {
const safe = Number.isFinite(n) && n >= 0 ? n : 0;
if (safe >= 100) return `$${safe.toFixed(0)}`;
if (safe >= 10) return `$${safe.toFixed(2)}`;
return `$${safe.toFixed(2)}`;
}
function fmtUptime(ms) {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
const remMin = min % 60;
return remMin > 0 ? `${hr}h${remMin}m` : `${hr}h`;
}
// ── Status dot — the ONE accent ──────────────────────────────────────────
function statusDot(pct, isStale) {
if (isStale) return red("●");
function statusDot(pct) {
if (pct >= 50) return green("●");
if (pct >= 1) return yellow("●");
return green("●");
}
// ── Main render ──────────────────────────────────────────────────────────
function main() {
async function main() {
readStdinJson(); // drain stdin even if unused, keeps Claude Code happy
const sessionDir = resolveSessionDir();
const sessionsDir = resolveSessionDir();
const sessionId = resolveSessionId();
const statsFile = findStatsFile(sessionDir, sessionId);
// BRAND-NEW — no stats file. Use only the substantiated README headline
// claim ("saves ~98% of context window"). No fabricated $/dev/month or
// social-proof numbers we cannot back with data.
if (!statsFile) {
const analytics = await loadAnalytics();
// BRAND-NEW / build missing — substantiated headline only
if (!analytics) {
process.stdout.write(
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
);
return;
}
const stats = loadStats(statsFile);
if (!stats) {
const {
getRealBytesStats,
getMultiAdapterLifetimeStats,
OPUS_INPUT_PRICE_PER_TOKEN,
} = analytics;
// Sessions dir doesn't exist yet — first ever launch
if (!existsSync(sessionsDir)) {
process.stdout.write(
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
);
return;
}
// STALE — stats file >30min old, MCP likely stopped
const ageMs = Date.now() - (stats.updated_at || 0);
const stale = ageMs > 30 * 60 * 1000;
if (stale) {
// Lifetime real-bytes across this adapter's sessions dir.
// Mirrors src/server.ts:2860 — the same call ctx_stats uses.
let lifetime;
try {
lifetime = getRealBytesStats({ sessionsDir });
} catch {
lifetime = null;
}
// Per-conversation real-bytes for the session $ KPI.
// Statusline doesn't know the worktree hash, so scan every db in the
// dir and let getRealBytesStats filter by sessionId.
let conversation;
try {
conversation = getRealBytesStats({ sessionsDir, sessionId });
} catch {
conversation = null;
}
// Cross-adapter lifetime — drives the "across N tools" headline when
// 2+ real adapters are present. Mirrors src/server.ts:2840.
let multi;
try {
multi = getMultiAdapterLifetimeStats();
} catch {
multi = null;
}
const PRICE = OPUS_INPUT_PRICE_PER_TOKEN ?? (15 / 1_000_000);
const lifetimeTokens = lifetime?.totalSavedTokens ?? 0;
const sessionTokens = conversation?.totalSavedTokens ?? 0;
const lifetimeUsd = lifetimeTokens * PRICE;
const sessionUsd = sessionTokens * PRICE;
// Reduction % — bytes avoided + snapshot bytes vs returned bytes.
// Mirrors persistStats() math in src/server.ts:565-568.
const totalReturned = lifetime?.bytesReturned ?? 0;
const totalKept =
(lifetime?.bytesAvoided ?? 0)
+ (lifetime?.snapshotBytes ?? 0)
+ (lifetime?.eventDataBytes ?? 0);
const totalProcessed = totalKept + totalReturned;
const pct = totalProcessed > 0
? Math.round((totalKept / totalProcessed) * 100)
: 0;
const dot = statusDot(pct);
// Multi-adapter aggregation. Real adapters = those passing the isReal
// filter (>=100 events, >=5 distinct projects, recent activity, avg
// bytes >= 50). When 2+ real adapters exist, surface a cross-tool $.
// multi.totalBytes is dataBytes + rescueBytes, NOT bytes-avoided — so
// it's a different (and typically smaller) lens than getRealBytesStats.
// Render the multi $ alongside lifetime $ rather than instead of it.
const realAdapters = (multi?.perAdapter ?? []).filter((a) => a?.isReal);
const multiTotalTokens = (multi?.totalBytes ?? 0) / 4;
const multiUsd = multiTotalTokens * PRICE;
const showMultiAdapter = realAdapters.length >= 2 && multiUsd > 0;
// BRAND-NEW: no local SessionDB data at all → headline.
// Multi-adapter alone (without local data) means another tool has
// history but THIS Claude session is fresh — still show headline,
// not someone else's lifetime $, to avoid surprising users with a
// number they can't trace to their current adapter.
if (lifetimeTokens === 0 && sessionTokens === 0) {
process.stdout.write(
`${brand("context-mode")} ${red("●")} ${dim("stale — restart to resume saving")}`,
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
);
return;
}
const sessionUsd = stats.dollars_saved_session ?? 0;
const lifetimeUsd = stats.dollars_saved_lifetime ?? 0;
const pct = stats.reduction_pct ?? 0;
const uptime = fmtUptime(stats.uptime_ms ?? 0);
const dot = statusDot(pct, false);
// FRESH — no session $ yet, lead with persistence value
if (sessionUsd === 0) {
if (lifetimeUsd > 0) {
// Lifetime $ exists — persistence as primary value, brand-poem echo
process.stdout.write(
`${brand("context-mode")} ${dot} ${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")} ${SEP} ${dim("preserved across compact, restart & upgrade")}`,
);
} else {
// First-ever session, no lifetime data yet — substantiated headline only
process.stdout.write(
`${brand("context-mode")} ${dot} ${dim("ready — saves ~98% of context window")}`,
);
// FRESH session, no session $ yet — lead with persistence value.
if (sessionUsd === 0 && lifetimeUsd > 0) {
const blocks = [
`${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`,
];
if (showMultiAdapter) {
blocks.push(`${bold(fmtUsd(multiUsd))} ${dim(`across ${realAdapters.length} tools`)}`);
}
blocks.push(dim("preserved across compact, restart & upgrade"));
process.stdout.write(
`${brand("context-mode")} ${dot} ${blocks.join(` ${SEP} `)}`,
);
return;
}
// ACTIVE / DEGRADED — session $ · [lifetime $ when present] · % efficient · uptime
// Status dot color encodes degraded vs healthy via pct.
// Lifetime block is conditional: persistStats omits dollars_saved_lifetime
// when no analytics aggregator is available, so we degrade gracefully to
// a session-only render rather than printing "$0.00 saved across sessions".
// ACTIVE: session $ · lifetime $ · [multi $] · % efficient
const valueBlocks = [
`${bold(fmtUsd(sessionUsd))} ${dim("saved this session")}`,
];
if (lifetimeUsd > 0) {
valueBlocks.push(`${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`);
}
valueBlocks.push(`${bold(`${pct}%`)} ${dim("efficient")}`);
valueBlocks.push(dim(uptime));
if (showMultiAdapter) {
valueBlocks.push(`${bold(fmtUsd(multiUsd))} ${dim(`across ${realAdapters.length} tools`)}`);
}
if (pct > 0) {
valueBlocks.push(`${bold(`${pct}%`)} ${dim("efficient")}`);
}
const head = `${brand("context-mode")} ${dot} `;
const tail = valueBlocks.join(` ${SEP} `);
process.stdout.write(head + tail);
}
main();
main().catch(() => {
// Last-resort fallback — a thrown error must never produce a blank statusline.
try {
process.stdout.write(
`${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
);
} catch { /* ignore */ }
});
+317
View File
@@ -0,0 +1,317 @@
/**
* Behavioral tests for the SessionDB-backed statusline pipeline.
*
* Until v1.0.111 the statusline read per-PID `stats-pid-*.json` sidecars
* written by `persistStats()` (src/server.ts:546). Sidecars are
* eventually-consistent (500ms+30s throttles), PID-scoped (multiple Claude
* sessions collide on shared shell ppid), and don't carry the multi-adapter
* aggregation `ctx_stats` already exposes.
*
* These tests pin the new contract: statusline reads directly from the
* same SessionDB (`session_events` + `session_resume`) that powers the
* `ctx_stats` MCP handler at src/server.ts:2807-2891. This means:
* - statusline reflects the current state, no sidecar lag
* - multiple sessions don't collide
* - multi-adapter aggregation works for cross-tool users
*
* Strategy: seed a real SessionDB fixture (no mocks of the analytics
* layer — that would couple tests to implementation). Drive the statusline
* end-to-end via spawnSync and assert on its public output.
*/
import { describe, test, beforeEach, afterEach } from "vitest";
import { strict as assert } from "node:assert";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
rmSync,
existsSync,
} from "node:fs";
import { tmpdir, homedir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import Database from "better-sqlite3";
const STATUSLINE = resolve(process.cwd(), "bin", "statusline.mjs");
function runStatusline(env: Record<string, string>) {
const result = spawnSync("node", [STATUSLINE], {
input: "{}",
env: { ...process.env, NO_COLOR: "1", ...env },
encoding: "utf-8",
});
return {
stdout: result.stdout.trim(),
stderr: result.stderr ?? "",
status: result.status,
};
}
/**
* Create a SessionDB sidecar matching the schema used by src/session/db.ts:273.
* `worktreeHash` defaults to a deterministic dummy — the statusline doesn't
* filter by worktree, so any 16-hex value works.
*/
function seedSessionDb(opts: {
dir: string;
worktreeHash?: string;
events: Array<{
sessionId?: string;
type?: string;
category?: string;
data?: string;
bytesAvoided?: number;
bytesReturned?: number;
}>;
resume?: { sessionId: string; snapshotBytes: number; eventCount?: number };
}): string {
const hash = opts.worktreeHash ?? "a".repeat(16);
const dbPath = join(opts.dir, `${hash}.db`);
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS session_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
type TEXT NOT NULL,
category TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 2,
data TEXT NOT NULL,
project_dir TEXT NOT NULL DEFAULT '',
attribution_source TEXT NOT NULL DEFAULT 'unknown',
attribution_confidence REAL NOT NULL DEFAULT 0,
bytes_avoided INTEGER NOT NULL DEFAULT 0,
bytes_returned INTEGER NOT NULL DEFAULT 0,
source_hook TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
data_hash TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS session_meta (
session_id TEXT PRIMARY KEY,
project_dir TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
last_event_at TEXT,
event_count INTEGER NOT NULL DEFAULT 0,
compact_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_resume (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
snapshot TEXT NOT NULL,
event_count INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
consumed INTEGER NOT NULL DEFAULT 0
);
`);
const insert = db.prepare(
`INSERT INTO session_events
(session_id, type, category, data, bytes_avoided, bytes_returned, source_hook)
VALUES (?, ?, ?, ?, ?, ?, '')`
);
const seenSessions = new Set<string>();
for (const ev of opts.events) {
const sid = ev.sessionId ?? "default-session";
insert.run(
sid,
ev.type ?? "tool_use",
ev.category ?? "tool",
ev.data ?? "x".repeat(256),
ev.bytesAvoided ?? 0,
ev.bytesReturned ?? 0,
);
seenSessions.add(sid);
}
const insertMeta = db.prepare(
`INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, '/tmp/test')`
);
for (const sid of seenSessions) insertMeta.run(sid);
if (opts.resume) {
db.prepare(
`INSERT INTO session_resume (session_id, snapshot, event_count) VALUES (?, ?, ?)`
).run(
opts.resume.sessionId,
"x".repeat(opts.resume.snapshotBytes),
opts.resume.eventCount ?? 1,
);
}
db.close();
return dbPath;
}
describe("statusline.mjs — SessionDB-backed reads", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "ctx-statusline-sqlite-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// SLICE 1: lifetime $ comes from SessionDB, not from sidecar JSON.
// Seed a SessionDB with substantial event data → statusline must render
// a lifetime $ derived from those bytes (NOT $0.00, NOT a stale sidecar).
test("renders lifetime $ from SessionDB session_events bytes", () => {
// 1000 events × ~256 bytes data = ~256KB → ~64K tokens → ~$0.96
// Use bytes_avoided so it counts as keptOut savings.
const events = Array.from({ length: 1000 }, () => ({
bytesAvoided: 1024, // 1KB avoided per event
data: "x".repeat(64),
}));
seedSessionDb({ dir, events });
const { stdout } = runStatusline({
CONTEXT_MODE_SESSION_DIR: dir,
CLAUDE_SESSION_ID: "any-session-id",
});
assert.match(stdout, /context-mode/, "brand visible");
// 1MB avoided ÷ 4 bytes/token = 256K tokens × $15/1M = $3.84 (and bytes_returned=0)
// Substantively positive lifetime $ — proves SessionDB is the source.
assert.match(
stdout,
/\$([1-9]\d*|0\.\d*[1-9])/,
"non-zero $ derived from SessionDB rows",
);
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
const { stdout } = runStatusline({
CONTEXT_MODE_SESSION_DIR: dir,
CLAUDE_SESSION_ID: "any-session-id",
});
assert.match(stdout, /context-mode/);
assert.match(stdout, /saves ~98% of context window/);
assert.doesNotMatch(stdout, /\$\d+\/dev\/month/);
});
});
// ── Slice 2: multi-adapter aggregation ───────────────────────────────────
// When 2+ real adapters are detected on disk, the statusline shows the
// multi-adapter total instead of just the active adapter's $. This mirrors
// the `multiAdapter` block ctx_stats already renders (src/server.ts:2840).
//
// `getMultiAdapterLifetimeStats({ home })` walks every adapter dir under
// `home`. We seed two adapter dirs with enough events to cross the
// `isReal` threshold (>=100 events, >=5 distinct projects, recent,
// avg bytes >= 50 — see DEFAULT_REAL_USAGE_FILTER at analytics.ts:1162).
describe("statusline.mjs — multi-adapter aggregation", () => {
let home: string;
let claudeSessionsDir: string;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "ctx-statusline-multi-"));
// Mirror real adapter layout: ~/.claude/context-mode/sessions for
// claude-code, ~/.gemini/context-mode/sessions for gemini-cli, etc.
claudeSessionsDir = join(home, ".claude", "context-mode", "sessions");
mkdirSync(claudeSessionsDir, { recursive: true });
mkdirSync(join(home, ".gemini", "context-mode", "sessions"), {
recursive: true,
});
});
afterEach(() => {
rmSync(home, { recursive: true, force: true });
});
function seedRealAdapter(sessionsDir: string, projectSeed: string) {
// 200 events across 6 distinct project_dirs, recent created_at, avg bytes ~256.
// Crosses the isReal filter at analytics.ts:1300-1304.
const dbPath = join(sessionsDir, `${createHash("sha256").update(projectSeed).digest("hex").slice(0, 16)}.db`);
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS session_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
type TEXT NOT NULL,
category TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 2,
data TEXT NOT NULL,
project_dir TEXT NOT NULL DEFAULT '',
attribution_source TEXT NOT NULL DEFAULT 'unknown',
attribution_confidence REAL NOT NULL DEFAULT 0,
bytes_avoided INTEGER NOT NULL DEFAULT 0,
bytes_returned INTEGER NOT NULL DEFAULT 0,
source_hook TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
data_hash TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS session_meta (
session_id TEXT PRIMARY KEY,
project_dir TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
last_event_at TEXT,
event_count INTEGER NOT NULL DEFAULT 0,
compact_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS session_resume (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
snapshot TEXT NOT NULL,
event_count INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
consumed INTEGER NOT NULL DEFAULT 0
);
`);
const ins = db.prepare(
`INSERT INTO session_events (session_id, type, category, data, project_dir, bytes_avoided, source_hook)
VALUES (?, 'tool_use', 'tool', ?, ?, 1024, '')`,
);
const meta = db.prepare(
`INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`,
);
for (let i = 0; i < 200; i++) {
const proj = `/p/${projectSeed}/${i % 6}`;
ins.run(`sid-${projectSeed}-${i}`, "x".repeat(256), proj);
meta.run(`sid-${projectSeed}-${i}`, proj);
}
db.close();
}
// Slice 2 RED: with TWO real adapters seeded under HOME, the statusline
// surfaces the cross-tool aggregate. Counts adapters via "across N tools".
test("renders 'across N tools' when 2+ real adapters detected", () => {
seedRealAdapter(join(home, ".claude", "context-mode", "sessions"), "claude");
seedRealAdapter(join(home, ".gemini", "context-mode", "sessions"), "gemini");
const { stdout } = runStatusline({
// statusline must use HOME for multi-adapter walk
HOME: home,
USERPROFILE: home,
// active adapter dir is the claude one (matches getSessionDir() default)
CONTEXT_MODE_SESSION_DIR: claudeSessionsDir,
CLAUDE_SESSION_ID: "any-session-id",
});
assert.match(stdout, /context-mode/);
assert.match(
stdout,
/across\s+\d+\s+tools?/i,
"shows multi-adapter aggregate when 2+ real adapters",
);
});
// Slice 2 cont: with only ONE real adapter, do NOT show "across N tools".
test("single real adapter: no 'across N tools' suffix", () => {
seedRealAdapter(join(home, ".claude", "context-mode", "sessions"), "claude");
const { stdout } = runStatusline({
HOME: home,
USERPROFILE: home,
CONTEXT_MODE_SESSION_DIR: claudeSessionsDir,
CLAUDE_SESSION_ID: "any-session-id",
});
assert.match(stdout, /context-mode/);
assert.doesNotMatch(
stdout,
/across\s+\d+\s+tools?/i,
"single adapter must not advertise multi-tool",
);
});
});