From 6c3df089f25961a498ea8ec28e649a49f1ce519a Mon Sep 17 00:00:00 2001 From: Huiyan Wan Date: Fri, 29 May 2026 12:05:03 +0100 Subject: [PATCH] fix(finder): default to the current session via CLAUDE_CODE_SESSION_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run with no args, cctime picked the most-recently-*modified* session file — which loses to any concurrently-active Claude session, so it could silently report a different session's stats than the one you're in (hit in practice: a 16h background session shadowed the 1h session being asked about). Claude Code exports CLAUDE_CODE_SESSION_ID to subprocesses. Resolve it via getSessionById (the same authoritative lookup --session uses) so the default is the actual current session. getSessionById matches by id and skips the messageCount>2 "main session" filter — important because an active session's cached index count can be stale/low and would otherwise exclude it from getAllSessions. When not inside Claude Code and ≥2 sessions were active in the last 5 min, print the chosen id + a --session hint instead of choosing silently. Adds getCurrentSessionId() + getAllSessions() to finder + 2 tests. 89 pass. Verified live: no-arg run now reports the current session, not a 16h sibling. --- CHANGELOG.md | 14 ++++++++++++++ src/finder.test.ts | 24 ++++++++++++++++++++++++ src/finder.ts | 22 +++++++++++++++++++--- src/index.ts | 35 +++++++++++++++++++++++++++++------ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ae374..593b6d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **Default to the *current* session, not "most recently modified file".** Run with no + args, `cctime` now resolves `CLAUDE_CODE_SESSION_ID` (which Claude Code exports to + subprocesses) via the authoritative by-id lookup, so it reports the session you're + actually in — previously it picked whichever session's JSONL was written last, which + loses to any concurrently-active session (and silently showed the wrong one). The by-id + path also skips the `messageCount>2` "main session" filter, which could drop an active + session whose cached index count is stale. When not running inside Claude Code and ≥2 + sessions were active in the last 5 min, it now prints which one it chose + a `--session` + hint instead of choosing silently. + ## [1.0.0] - 2026-02-18 ### Added diff --git a/src/finder.test.ts b/src/finder.test.ts index 0c1cf4c..f0f4cee 100644 --- a/src/finder.test.ts +++ b/src/finder.test.ts @@ -29,3 +29,27 @@ describe('finder: session filtering', () => { expect(count).toBeLessThanOrEqual(2); }); }); + +describe('finder: current session detection', () => { + const KEY = 'CLAUDE_CODE_SESSION_ID'; + let saved: string | undefined; + beforeEach(() => { saved = process.env[KEY]; }); + afterEach(() => { + if (saved === undefined) delete process.env[KEY]; + else process.env[KEY] = saved; + }); + + it('returns CLAUDE_CODE_SESSION_ID when running inside a Claude Code session', async () => { + const { getCurrentSessionId } = await import('./finder.js'); + process.env[KEY] = 'abc123-def'; + expect(getCurrentSessionId()).toBe('abc123-def'); + }); + + it('returns undefined when the env var is unset or blank', async () => { + const { getCurrentSessionId } = await import('./finder.js'); + delete process.env[KEY]; + expect(getCurrentSessionId()).toBeUndefined(); + process.env[KEY] = ' '; + expect(getCurrentSessionId()).toBeUndefined(); + }); +}); diff --git a/src/finder.ts b/src/finder.ts index 1582312..2447692 100644 --- a/src/finder.ts +++ b/src/finder.ts @@ -92,7 +92,19 @@ function filterMainSessions(entries: SessionIndexEntry[]): SessionIndexEntry[] { return entries.filter(e => !e.isSidechain && e.messageCount > 2); } -export async function getLastSession(projectFilter?: string): Promise { +/** The session id of the Claude Code session this process is running inside, + * if any. Claude Code exposes it to subprocesses as CLAUDE_CODE_SESSION_ID — + * this lets `cctime` (no args) default to the ACTUAL current session instead + * of "the most recently modified file", which loses to any concurrently-active + * session. Returns undefined when run outside Claude Code (e.g. a plain shell). */ +export function getCurrentSessionId(): string | undefined { + const id = process.env.CLAUDE_CODE_SESSION_ID; + return id && id.trim() ? id.trim() : undefined; +} + +/** All main (non-sidechain) sessions across projects, most-recently-modified + * first. Shared by getLastSession and the no-arg default selection. */ +export async function getAllSessions(projectFilter?: string): Promise { const dirs = await listProjectDirs(); let allEntries: SessionIndexEntry[] = []; @@ -107,9 +119,13 @@ export async function getLastSession(projectFilter?: string): Promise new Date(b.modified).getTime() - new Date(a.modified).getTime()); - return allEntries[0]; + return allEntries; +} + +export async function getLastSession(projectFilter?: string): Promise { + const all = await getAllSessions(projectFilter); + return all[0] ?? null; } export async function getTodaySessions(projectFilter?: string): Promise { diff --git a/src/index.ts b/src/index.ts index 2995f98..1329282 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { program } from 'commander'; -import { getLastSession, getTodaySessions, getWeekSessions, getMonthSessions, getSessionsSince, getSessionById } from './finder.js'; +import { getLastSession, getAllSessions, getCurrentSessionId, getTodaySessions, getWeekSessions, getMonthSessions, getSessionsSince, getSessionById } from './finder.js'; import { parseSession } from './parser.js'; import { analyzeSession } from './analyzer.js'; import { formatSession, formatAggregate, formatCompact, formatCsv, formatMarkdown, formatJsonAggregate } from './formatter.js'; @@ -191,12 +191,35 @@ program entries = await getTodaySessions(opts.project); label = 'Today'; } else { - // Default: last session - const entry = await getLastSession(opts.project); + // Default: the CURRENT session if we're running inside one, else the + // most recently active. Prefer CLAUDE_CODE_SESSION_ID so `cctime` (no + // args) reports the session you're actually in — not whichever + // concurrent session happened to write its file last. Resolve the + // current id via getSessionById (the same authoritative lookup --session + // uses): it matches by id and skips the messageCount>2 "main session" + // filter, which can otherwise drop an active session whose cached index + // count is stale/low. + const currentId = getCurrentSessionId(); + let entry: SessionIndexEntry | null = currentId + ? await getSessionById(currentId, opts.project) + : null; if (!entry) { - console.error('No sessions found.'); - console.error('Try running Claude Code first to create session files.'); - process.exit(1); + const all = await getAllSessions(opts.project); + if (all.length === 0) { + console.error('No sessions found.'); + console.error('Try running Claude Code first to create session files.'); + process.exit(1); + } + entry = all[0]; + // Heads-up when the pick is ambiguous: several sessions were written + // to in the last few minutes, so "most recent" may not be the one you + // mean. (Silent when we matched the current session — that's exact.) + const ACTIVE_WINDOW_MS = 5 * 60 * 1000; + const now = Date.now(); + const recentlyActive = all.filter(e => now - new Date(e.modified).getTime() < ACTIVE_WINDOW_MS); + if (recentlyActive.length > 1) { + console.error(`cctime: ${recentlyActive.length} sessions were active in the last 5 min; showing the most recent (${entry.sessionId.slice(0, 8)}). Use --session to pick another.`); + } } const analysis = await analyzeEntry(entry); if (opts.csv || opts.compact || opts.markdown) {