diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 53a1437..83c9a82 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -14,6 +14,7 @@ import { import { buildSessionRuntimeAuthInstructions, getSessionRuntimeAuthInputFromPayload, + getPersistedSessionAuthMode, getPersistedSessionBaseUrl, resolveSessionRuntimeAuthConfig, } from "@/core/session-runtime-auth"; @@ -123,7 +124,7 @@ export async function POST(req: NextRequest) { typeof branch_name === "string" ? branch_name : null, agentConfig.codingAgent.id, agentConfig.agentTeam.id, - runtimeAuthConfig.mode, + getPersistedSessionAuthMode(runtimeAuthConfig), runtimeAuthConfig.agentApiKeyEnvVar, runtimeAuthConfig.localCliAgentId, runtimeAuthConfig.model, diff --git a/src/cli/commands/setup-statusline.ts b/src/cli/commands/setup-statusline.ts index 2396f81..d98bc03 100644 --- a/src/cli/commands/setup-statusline.ts +++ b/src/cli/commands/setup-statusline.ts @@ -5,6 +5,7 @@ import { execFileSync } from "child_process"; import { fileURLToPath } from "url"; import chalk from "chalk"; import { resolvePackageRoot } from "../lib/package-root.js"; +import { loadClaudeSettings, writeClaudeSettingsWithBackup } from "../lib/claude-settings.js"; import { ensureInit } from "../../core/config.js"; import { discoverProjects, computeStats } from "../../core/discovery.js"; import { updateCacheFromStats } from "../../core/cache.js"; @@ -83,14 +84,13 @@ export async function setupStatuslineCommand(): Promise { // ── Claude Code: statusLine + hooks ─────────────────── const claudeSettingsPath = join(homedir(), ".claude", "settings.json"); - let settings: Record = {}; - if (existsSync(claudeSettingsPath)) { - try { - settings = JSON.parse(readFileSync(claudeSettingsPath, "utf-8")); - } catch { - settings = {}; - } + const loaded = loadClaudeSettings(claudeSettingsPath); + if (!loaded.ok) { + console.error(chalk.red(`\n ${loaded.error}`)); + console.error(chalk.dim(" Fix or remove the file, then re-run. Nothing was modified.\n")); + process.exit(1); } + const settings = loaded.settings; settings.statusLine = { type: "command", @@ -106,8 +106,7 @@ export async function setupStatuslineCommand(): Promise { } settings.hooks = hooks; - mkdirSync(dirname(claudeSettingsPath), { recursive: true }); - writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); + writeClaudeSettingsWithBackup(claudeSettingsPath, settings); console.log(); console.log(chalk.green(" \u2713") + chalk.bold.white(" Claude Code status line + hooks configured")); diff --git a/src/cli/commands/setup-tmux.ts b/src/cli/commands/setup-tmux.ts index 067a8e0..095e6ba 100644 --- a/src/cli/commands/setup-tmux.ts +++ b/src/cli/commands/setup-tmux.ts @@ -5,10 +5,12 @@ import { execFileSync } from "child_process"; import { fileURLToPath } from "url"; import chalk from "chalk"; import { resolvePackageRoot } from "../lib/package-root.js"; +import { loadClaudeSettings, writeClaudeSettingsWithBackup } from "../lib/claude-settings.js"; interface Hook { - type: string; - command: string; + type?: string; + command?: string; + [key: string]: unknown; } interface ClaudeSettings { @@ -26,7 +28,7 @@ function makeHookCommand(state: string): string { } function removeExistingDevlogHooks(hooks: Hook[]): Hook[] { - return hooks.filter((h) => !h.command.includes(".claude-status")); + return hooks.filter((h) => !h.command?.includes(".claude-status")); } function generateTmuxScript(devlogBin: string): string { @@ -103,14 +105,13 @@ export async function setupTmuxCommand(): Promise { // ── Step 1: Install Claude Code hooks into ~/.claude/settings.json ── const claudeSettingsPath = join(homedir(), ".claude", "settings.json"); - let settings: ClaudeSettings = {}; - if (existsSync(claudeSettingsPath)) { - try { - settings = JSON.parse(readFileSync(claudeSettingsPath, "utf-8")); - } catch { - settings = {}; - } + const loaded = loadClaudeSettings(claudeSettingsPath); + if (!loaded.ok) { + console.error(chalk.red(`\n ${loaded.error}`)); + console.error(chalk.dim(" Fix or remove the file, then re-run. Nothing was modified.\n")); + process.exit(1); } + const settings = loaded.settings as ClaudeSettings; // Ensure hooks structure exists if (!settings.hooks) { @@ -130,8 +131,7 @@ export async function setupTmuxCommand(): Promise { stop.push({ type: "command", command: makeHookCommand("idle") }); settings.hooks.Stop = stop; - mkdirSync(dirname(claudeSettingsPath), { recursive: true }); - writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); + writeClaudeSettingsWithBackup(claudeSettingsPath, settings); console.log(); console.log(chalk.green(" \u2713") + chalk.bold.white(" Claude Code hooks installed")); diff --git a/src/cli/lib/claude-settings.ts b/src/cli/lib/claude-settings.ts new file mode 100644 index 0000000..3749bab --- /dev/null +++ b/src/cli/lib/claude-settings.ts @@ -0,0 +1,59 @@ +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname } from "path"; + +export type ClaudeSettings = Record; + +function isFileMissing(err: unknown): boolean { + return (err as NodeJS.ErrnoException)?.code === "ENOENT"; +} + +/** + * Reads ~/.claude/settings.json without destroying it (IM-18). A parse + * failure used to fall back to `{}` and the setup commands then overwrote + * the user's file with a devlog-only object — corrupt settings must abort + * instead, and writes keep a .bak of the previous content. + */ +export function loadClaudeSettings( + path: string, +): + | { ok: true; settings: ClaudeSettings } + | { ok: false; error: string } { + let raw: string; + try { + raw = readFileSync(path, "utf-8"); + } catch (err) { + if (isFileMissing(err)) { + return { ok: true, settings: {} }; + } + return { + ok: false, + error: `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, + }; + } + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return { ok: true, settings: parsed as ClaudeSettings }; + } + return { ok: false, error: `${path} is not a JSON object` }; + } catch (err) { + return { + ok: false, + error: `Could not parse ${path}: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +export function writeClaudeSettingsWithBackup( + path: string, + settings: ClaudeSettings, +): void { + mkdirSync(dirname(path), { recursive: true }); + try { + copyFileSync(path, `${path}.bak`); + } catch (err) { + // No existing file means nothing to back up. + if (!isFileMissing(err)) throw err; + } + writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf-8"); +} diff --git a/src/core/__tests__/claude-settings.test.ts b/src/core/__tests__/claude-settings.test.ts new file mode 100644 index 0000000..7c937e0 --- /dev/null +++ b/src/core/__tests__/claude-settings.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadClaudeSettings, + writeClaudeSettingsWithBackup, +} from "../../cli/lib/claude-settings"; + +/** + * Regression tests for IM-18 (REVIEW-2026-06-10): a corrupt + * ~/.claude/settings.json was parsed to `{}` and silently overwritten with + * a devlog-only object — destroying the user's hooks, model config, etc. + */ + +function tempPath(): { dir: string; path: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "devlog-settings-")); + return { + dir, + path: join(dir, "settings.json"), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +test("corrupt settings files abort instead of resolving to {} (IM-18)", () => { + const { path, cleanup } = tempPath(); + try { + writeFileSync(path, "{ definitely not json"); + const result = loadClaudeSettings(path); + assert.equal(result.ok, false); + } finally { + cleanup(); + } +}); + +test("missing files load as empty settings; valid files round-trip", () => { + const { path, cleanup } = tempPath(); + try { + const missing = loadClaudeSettings(path); + assert.deepEqual(missing, { ok: true, settings: {} }); + + writeFileSync(path, JSON.stringify({ model: "opus" })); + const loaded = loadClaudeSettings(path); + assert.equal(loaded.ok, true); + if (loaded.ok) assert.equal(loaded.settings.model, "opus"); + } finally { + cleanup(); + } +}); + +test("writes keep a .bak of the previous content (IM-18)", () => { + const { path, cleanup } = tempPath(); + try { + writeFileSync(path, JSON.stringify({ precious: true })); + writeClaudeSettingsWithBackup(path, { precious: true, statusLine: {} }); + + const backup = JSON.parse(readFileSync(`${path}.bak`, "utf-8")); + assert.deepEqual(backup, { precious: true }); + const current = JSON.parse(readFileSync(path, "utf-8")); + assert.equal(current.precious, true); + assert.ok("statusLine" in current); + } finally { + cleanup(); + } +}); diff --git a/src/core/__tests__/discovery-cache.test.ts b/src/core/__tests__/discovery-cache.test.ts new file mode 100644 index 0000000..dca1612 --- /dev/null +++ b/src/core/__tests__/discovery-cache.test.ts @@ -0,0 +1,90 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearDiscoveryCache, + discoverProjects, + getDiscoveryCacheStats, +} from "../discovery"; + +/** + * Regression tests for IM-23 (REVIEW-2026-06-10): every dashboard poll + * re-parsed every JSONL under ~/.claude/projects serially — multi-second + * CPU+IO per request at a few thousand sessions. Scans are now memoized by + * (path, mtime, size) and only changed files are re-read. + */ + +function line(text: string, timestamp: string): string { + return ( + JSON.stringify({ + type: "assistant", + timestamp, + message: { role: "assistant", content: [{ type: "text", text }] }, + }) + "\n" + ); +} + +function makeProjectsDir(): { dir: string; file: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "devlog-discovery-")); + const projectDir = join(dir, "-tmp-proj-a"); + mkdirSync(projectDir); + const file = join(projectDir, "session-1.jsonl"); + writeFileSync(file, line("hello", "2026-06-01T10:00:00.000Z")); + return { dir, file, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +test("unchanged files are served from the scan cache (IM-23)", async () => { + const { dir, cleanup } = makeProjectsDir(); + try { + clearDiscoveryCache(); + + const first = await discoverProjects(dir); + assert.equal(first.length, 1); + assert.equal(first[0].sessions[0].meta.assistantTurns, 1); + const afterFirst = getDiscoveryCacheStats(); + assert.equal(afterFirst.misses, 1); + assert.equal(afterFirst.hits, 0); + + const second = await discoverProjects(dir); + assert.equal(second[0].sessions[0].meta.assistantTurns, 1); + const afterSecond = getDiscoveryCacheStats(); + assert.equal(afterSecond.misses, 1); + assert.equal(afterSecond.hits, 1); + } finally { + cleanup(); + } +}); + +test("changed files are rescanned and fresh metadata returned", async () => { + const { dir, file, cleanup } = makeProjectsDir(); + try { + clearDiscoveryCache(); + await discoverProjects(dir); + + appendFileSync(file, line("more work", "2026-06-01T11:00:00.000Z")); + + const after = await discoverProjects(dir); + assert.equal(after[0].sessions[0].meta.assistantTurns, 2); + const stats = getDiscoveryCacheStats(); + assert.equal(stats.misses, 2); + } finally { + cleanup(); + } +}); + +test("cache entries for deleted files are pruned", async () => { + const { dir, file, cleanup } = makeProjectsDir(); + try { + clearDiscoveryCache(); + await discoverProjects(dir); + assert.equal(getDiscoveryCacheStats().entries, 1); + + rmSync(file); + await discoverProjects(dir); + assert.equal(getDiscoveryCacheStats().entries, 0); + } finally { + cleanup(); + } +}); diff --git a/src/core/__tests__/legacy-auth-mode.test.ts b/src/core/__tests__/legacy-auth-mode.test.ts new file mode 100644 index 0000000..34f5b33 --- /dev/null +++ b/src/core/__tests__/legacy-auth-mode.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + getPersistedSessionAuthMode, + resolveSessionRuntimeAuthConfig, + resolveStoredSessionRuntimeAuthConfig, +} from "../session-runtime-auth"; + +/** + * Regression test for IM-7 (REVIEW-2026-06-10): legacy agent-api-key + * sessions were persisted as "anthropic-api-key", losing the env-var + * marker — the next stored-config resolution treated them as browser BYOK + * and threw "API key is required" although preflight had passed. + */ + +test("legacy env-var sessions survive a persist/resolve round-trip (IM-7)", () => { + const original = resolveSessionRuntimeAuthConfig({ + session_auth_mode: "agent-api-key", + agent_api_key_env_var: "MY_BACKEND_KEY", + }); + assert.equal(original.usesLegacyEnvVar, true); + assert.equal(original.agentApiKeyEnvVar, "MY_BACKEND_KEY"); + + const persistedMode = getPersistedSessionAuthMode(original); + assert.equal(persistedMode, "agent-api-key"); + + // Simulate the next turn: stored row resolved with no transient key. + const restored = resolveStoredSessionRuntimeAuthConfig({ + session_auth_mode: persistedMode, + agent_api_key_env_var: "MY_BACKEND_KEY", + }); + assert.equal(restored.usesLegacyEnvVar, true); + assert.equal(restored.agentApiKeyEnvVar, "MY_BACKEND_KEY"); + assert.equal(restored.anthropicApiKey, null); +}); + +test("browser BYOK sessions persist their bare mode", () => { + const config = resolveSessionRuntimeAuthConfig({ + session_auth_mode: "anthropic-api-key", + anthropic_api_key: "sk-ant-test", + }); + assert.equal(config.usesLegacyEnvVar, false); + assert.equal(getPersistedSessionAuthMode(config), "anthropic-api-key"); + + const localCli = resolveSessionRuntimeAuthConfig({ + session_auth_mode: "local-cli", + }); + assert.equal(getPersistedSessionAuthMode(localCli), "local-cli"); +}); diff --git a/src/core/discovery.ts b/src/core/discovery.ts index 6c6bc48..8106274 100644 --- a/src/core/discovery.ts +++ b/src/core/discovery.ts @@ -1,7 +1,7 @@ import { readdir, stat } from "fs/promises"; import { join } from "path"; import { existsSync } from "fs"; -import type { Project, Session, AggregateStats } from "./types"; +import type { Project, Session, SessionMeta, AggregateStats } from "./types"; import { decodePath, getProjectName, @@ -10,6 +10,69 @@ import { import { scanSession } from "./parser"; import dayjs from "dayjs"; +// ── Incremental scan cache (IM-23) ───────────────────────── +// Every dashboard poll used to re-parse every JSONL serially. SessionMeta is +// memoized per file keyed by (mtime, size); only changed files are re-read, +// and scans run with bounded concurrency. Cached meta objects are shared — +// callers treat them as read-only. + +const SCAN_CONCURRENCY = 8; + +interface CachedScan { + mtimeMs: number; + size: number; + meta: SessionMeta; +} + +const scanCache = new Map(); +let cacheHits = 0; +let cacheMisses = 0; + +export function getDiscoveryCacheStats(): { + hits: number; + misses: number; + entries: number; +} { + return { hits: cacheHits, misses: cacheMisses, entries: scanCache.size }; +} + +export function clearDiscoveryCache(): void { + scanCache.clear(); + cacheHits = 0; + cacheMisses = 0; +} + +/** Drops cache entries under `projectsDir` that weren't seen this pass. */ +function pruneScanCache(projectsDir: string, seen: Set): void { + const prefix = projectsDir.endsWith("/") ? projectsDir : `${projectsDir}/`; + for (const key of scanCache.keys()) { + if (key.startsWith(prefix) && !seen.has(key)) { + scanCache.delete(key); + } + } +} + +async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: (R | null)[] = new Array(items.length).fill(null); + let next = 0; + const workers = Array.from( + { length: Math.max(1, Math.min(limit, items.length)) }, + async () => { + for (;;) { + const index = next++; + if (index >= items.length) break; + results[index] = await fn(items[index]); + } + } + ); + await Promise.all(workers); + return results.filter((r): r is Awaited => r !== null); +} + /** * Discover all Claude Code projects and sessions with rich metadata. * Accepts an optional progress callback for spinner updates. @@ -26,6 +89,7 @@ export async function discoverProjects( const entries = await readdir(projectsDir, { withFileTypes: true }); const projects: Project[] = []; + const seenFiles = new Set(); for (const entry of entries) { if (!entry.isDirectory()) continue; @@ -39,7 +103,8 @@ export async function discoverProjects( const sessions = await discoverSessions( projectDir, decodedPath, - projectName + projectName, + seenFiles ); if (sessions.length > 0) { @@ -53,6 +118,8 @@ export async function discoverProjects( } } + pruneScanCache(projectsDir, seenFiles); + // Sort projects by most recent session projects.sort((a, b) => { const aLatest = Math.max( @@ -70,28 +137,54 @@ export async function discoverProjects( async function discoverSessions( projectDir: string, decodedPath: string, - projectName: string + projectName: string, + seenFiles?: Set ): Promise { - const sessions: Session[] = []; - + let files: string[]; try { const entries = await readdir(projectDir, { withFileTypes: true }); + files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => entry.name); + } catch { + return []; + } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; - - const filePath = join(projectDir, entry.name); - const sessionId = entry.name.replace(".jsonl", ""); + const sessions = await mapWithConcurrency( + files, + SCAN_CONCURRENCY, + async (name): Promise => { + const filePath = join(projectDir, name); + const sessionId = name.replace(".jsonl", ""); try { const fileStat = await stat(filePath); - const meta = await scanSession(filePath); + seenFiles?.add(filePath); + + const cached = scanCache.get(filePath); + let meta: SessionMeta; + if ( + cached && + cached.mtimeMs === fileStat.mtimeMs && + cached.size === fileStat.size + ) { + cacheHits++; + meta = cached.meta; + } else { + cacheMisses++; + meta = await scanSession(filePath); + scanCache.set(filePath, { + mtimeMs: fileStat.mtimeMs, + size: fileStat.size, + meta, + }); + } // Use internal timestamps when available (more accurate than fs mtime) const createdAt = meta.firstActivity.getTime() > 0 ? meta.firstActivity : fileStat.birthtime; const updatedAt = meta.lastActivity.getTime() > 0 ? meta.lastActivity : fileStat.mtime; - sessions.push({ + return { id: sessionId, projectPath: decodedPath, projectName, @@ -99,14 +192,12 @@ async function discoverSessions( createdAt, updatedAt, meta, - }); + }; } catch { - continue; + return null; } } - } catch { - return []; - } + ); sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); return sessions; diff --git a/src/core/session-runtime-auth.ts b/src/core/session-runtime-auth.ts index f8c944b..5f057df 100644 --- a/src/core/session-runtime-auth.ts +++ b/src/core/session-runtime-auth.ts @@ -418,6 +418,19 @@ export function getPersistedSessionBaseUrl( return config.baseUrl ?? DEFAULT_BASE_URL_BY_PROTOCOL[config.apiProtocol]; } +/** + * The session_auth_mode value to persist (IM-7). Legacy env-var sessions + * resolve to mode "anthropic-api-key" with usesLegacyEnvVar set — storing + * the bare mode loses that marker, and the next stored-config resolution + * treats the session as browser BYOK and fails with "API key is required" + * even though preflight passed. + */ +export function getPersistedSessionAuthMode( + config: SessionRuntimeAuthConfig, +): string { + return config.usesLegacyEnvVar ? "agent-api-key" : config.mode; +} + export function buildClaudeProcessEnv( baseEnv: NodeJS.ProcessEnv, config: SessionRuntimeAuthConfig,