From e85215789a83bfb2a613101b0e1c7c1f0aafed36 Mon Sep 17 00:00:00 2001 From: Serge Arbuzov Date: Tue, 3 Mar 2026 23:25:59 +0300 Subject: [PATCH] feat: enhance session handling and discovery - Implemented asynchronous reading of session files to improve performance and prevent blocking the event loop. - Introduced caching mechanism to skip unchanged session files based on modification time. - Added functionality to skip oversized session files during reading. - Enhanced session discovery to support global scanning of all workspaces and project-specific filtering. - Updated session panel to allow filtering by current project or all projects. - Improved session rendering in the webview to display project names and current workspace status. - Refactored session parsing to handle large JSONL files more efficiently. - Added tests to ensure correct parsing and handling of session data. Signed-off-by: Serge Arbuzov --- CHANGELOG.md | 12 ++ README.md | 5 +- package-lock.json | 4 +- package.json | 7 +- src/diagnostics.test.ts | 8 +- src/extension.ts | 17 +- src/models/session.ts | 4 + src/parsers/claudeLocator.test.ts | 44 ++++- src/parsers/claudeLocator.ts | 123 +++++++++++- src/parsers/claudeProvider.ts | 199 +++++++++++++------ src/parsers/claudeSessionParser.test.ts | 166 ++++++++-------- src/parsers/claudeSessionParser.ts | 13 +- src/parsers/copilotProvider.test.ts | 253 ++++++++++++++++++++++-- src/parsers/copilotProvider.ts | 212 ++++++++++++++++++-- src/parsers/sessionParser.test.ts | 88 ++++----- src/parsers/sessionParser.ts | 13 +- src/views/sessionPanel.ts | 14 +- webview/session.ts | 243 ++++++++++++++++++----- webview/timeline.ts | 6 +- 19 files changed, 1135 insertions(+), 296 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534f567..bc5f498 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Cross-project session discovery: browse sessions from **all** Claude and Copilot projects — not just the current workspace. Sessions grouped by project name with "(current)" marker for the active workspace. +- `agentLens.discoverAllProjects` setting (default: `true`) — toggle between all-projects and current-workspace-only discovery +- Session Explorer: scope toggle to switch between "All Projects" and "Current Project" views +- Session Explorer: project name badges and grouped section headers when viewing all projects +- Claude locator: `decodeProjectName()` extracts human-readable project names from encoded directory paths +- Claude locator: `discoverAllClaudeProjects()` scans all subdirectories in `~/.claude/projects/` +- Copilot provider: `scanAllWorkspaceStorageDirs()` scans all workspace storage hash directories for global discovery + +### Fixed +- Extension host crash (`UNRESPONSIVE extension host`) on large Copilot session files (e.g. 75 MB). Two-part fix: (1) session files larger than 15 MB are now skipped with a debug log entry, preventing the event loop from being blocked by monster files; (2) `parseSessionJsonl` and `parseClaudeSessionJsonl` are now async and yield the event loop every 500 lines, preventing medium-sized files from blocking for multiple seconds. + ## [0.1.1] - 2026-03-02 ### Fixed diff --git a/README.md b/README.md index 9f2838f..1ff7304 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Interactive DAG visualization of your agents, skills, and handoff connections. Z ### Session Explorer -Replay individual sessions as a timeline. See each request's agent, model, tokens, tool calls, and timing. Spot agent switches and model changes. Sessions that used a custom agent or Copilot skills show compact badges at a glance. +Replay individual sessions as a timeline. See each request's agent, model, tokens, tool calls, and timing. Spot agent switches and model changes. Sessions that used a custom agent or Copilot skills show compact badges at a glance. Browse sessions across all your projects or filter to just the current workspace. ### Cache Token Metrics @@ -61,7 +61,7 @@ Session data stays local — Agent Lens only reads files already on your machine 3. Look for the **Agent Lens** icon in the activity bar 4. Click **Show Metrics Dashboard** or **Session Explorer** to explore your sessions -Agent Lens automatically discovers sessions for your current workspace. No configuration needed in most cases. +Agent Lens automatically discovers sessions across **all your projects** by default. No configuration needed in most cases. ### Devcontainers & Remote SSH @@ -72,6 +72,7 @@ If your sessions live on a mounted host path, configure the directory manually: | `agentLens.sessionDir` | Path to Copilot chat session files (or a `workspaceStorage` root) | | `agentLens.claudeDir` | Path to Claude Code project files (e.g., a mounted `~/.claude/projects`) | | `agentLens.codexDir` | Path to Codex CLI sessions directory (e.g., a mounted `~/.codex/sessions`) | +| `agentLens.discoverAllProjects` | Discover sessions from all projects (default: `true`). Disable to show only current workspace sessions. | Use the **Agent Lens: Container Setup Guide** command for step-by-step instructions. diff --git a/package-lock.json b/package-lock.json index 1d167dc..28ad7ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-lens", - "version": "0.0.17", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-lens", - "version": "0.0.17", + "version": "0.1.1", "license": "MIT", "dependencies": { "d3": "^7.9.0", diff --git a/package.json b/package.json index 9b90b4b..d212525 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "agent-lens", "displayName": "Agent Lens", "description": "Visualize your AI coding agents, skills, and handoffs as an interactive graph. Parses chat sessions to surface usage metrics.", - "version": "0.1.1", + "version": "0.1.2", "publisher": "Proliminal", "license": "MIT", "icon": "assets/icon_128.png", @@ -92,6 +92,11 @@ "default": "", "scope": "machine-overridable", "markdownDescription": "Path to OpenAI Codex CLI sessions directory. Useful in devcontainers — mount the host's `~/.codex/sessions` and point this setting to the mount path. Respects `CODEX_HOME` environment variable as fallback." + }, + "agentLens.discoverAllProjects": { + "type": "boolean", + "default": true, + "markdownDescription": "Discover sessions from **all** projects across Claude, Copilot, and Codex — not just the current workspace. Disable to show only sessions for the open workspace." } } }, diff --git a/src/diagnostics.test.ts b/src/diagnostics.test.ts index 3c61051..5be1ed5 100644 --- a/src/diagnostics.test.ts +++ b/src/diagnostics.test.ts @@ -110,11 +110,13 @@ describe("collectDiagnostics", () => { const indexPath = projectDir + "/sessions-index.json"; mockAccess.mockImplementation(async (p) => { - if (String(p) === projectDir || String(p) === indexPath) return undefined; + const ps = String(p).replace(/\\/g, "/"); + if (ps === projectDir || ps === indexPath) return undefined; throw new Error("ENOENT"); }); mockReadFile.mockImplementation(async (p) => { - if (String(p) === indexPath) { + const ps = String(p).replace(/\\/g, "/"); + if (ps === indexPath) { return JSON.stringify({ entries: [ { sessionId: "a", fullPath: "/a.jsonl" }, @@ -166,7 +168,7 @@ describe("collectDiagnostics", () => { describe("Codex diagnostics", () => { it("reports default path with recursive file count", async () => { mockAccess.mockImplementation(async (p) => { - if (String(p).includes(".codex/sessions")) return undefined; + if (String(p).replace(/\\/g, "/").includes(".codex/sessions")) return undefined; throw new Error("ENOENT"); }); mockReaddir.mockImplementation(async (p) => { diff --git a/src/extension.ts b/src/extension.ts index 3ef1171..f5a8501 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,10 +26,19 @@ let cachedAgents: Agent[] = []; let cachedSkills: Skill[] = []; let cachedSessions: Session[] = []; +let refreshing = false; +let refreshQueued = false; + async function refresh( sessionCtx: SessionDiscoveryContext, treeProvider: AgentLensTreeProvider, ): Promise { + if (refreshing) { + refreshQueued = true; + return; + } + refreshing = true; + const log = getLogger(); const start = Date.now(); log.debug("Refresh started"); @@ -78,6 +87,12 @@ async function refresh( } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`Refresh failed: ${msg}`); + } finally { + refreshing = false; + if (refreshQueued) { + refreshQueued = false; + void refresh(sessionCtx, treeProvider); + } } } @@ -193,7 +208,7 @@ export function activate(context: vscode.ExtensionContext): void { let refreshTimer: ReturnType | undefined; function scheduleRefresh() { if (refreshTimer) clearTimeout(refreshTimer); - refreshTimer = setTimeout(() => refresh(sessionCtx, treeProvider), 500); + refreshTimer = setTimeout(() => refresh(sessionCtx, treeProvider), 2000); } const agentWatcher = vscode.workspace.createFileSystemWatcher( diff --git a/src/models/session.ts b/src/models/session.ts index 0f44182..3a94497 100644 --- a/src/models/session.ts +++ b/src/models/session.ts @@ -51,4 +51,8 @@ export interface Session { matchedWorkspace?: string; /** vscode.env.remoteName at the time of discovery, e.g. "ssh-remote", "dev-container", null for local */ environment?: string | null; + /** Human-readable project name, e.g. "agent-lens", "pi-zero-hw-management" */ + projectName?: string; + /** true when the session belongs to the currently open VS Code workspace */ + isCurrentWorkspace?: boolean; } diff --git a/src/parsers/claudeLocator.test.ts b/src/parsers/claudeLocator.test.ts index f0aacf5..8497fa2 100644 --- a/src/parsers/claudeLocator.test.ts +++ b/src/parsers/claudeLocator.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { encodeProjectPath, encodedPathVariants, parseSessionIndex } from "./claudeLocator.js"; +import { encodeProjectPath, encodedPathVariants, parseSessionIndex, decodeProjectName } from "./claudeLocator.js"; describe("encodeProjectPath", () => { it("replaces slashes with dashes", () => { @@ -17,6 +17,20 @@ describe("encodeProjectPath", () => { "-Users-peterbru-project", ); }); + + it("handles Windows backslash paths", () => { + expect(encodeProjectPath("C:\\Users\\info\\project")).toBe( + "C--Users-info-project", + ); + }); + + it("handles Windows drive letter colons", () => { + expect(encodeProjectPath("C:\\Users\\foo")).toBe("C--Users-foo"); + }); + + it("strips trailing backslash", () => { + expect(encodeProjectPath("C:\\Users\\foo\\")).toBe("C--Users-foo"); + }); }); describe("encodedPathVariants", () => { @@ -116,3 +130,31 @@ describe("parseSessionIndex", () => { expect(entries[0].subagentPaths).toEqual([]); }); }); + +describe("decodeProjectName", () => { + it("extracts project name after -git-", () => { + expect(decodeProjectName("C--Users-info-Documents-git-agent-lens")).toBe("agent-lens"); + }); + + it("extracts project name after -workspaces-", () => { + expect(decodeProjectName("-workspaces-foo")).toBe("foo"); + }); + + it("handles project with dashes after known parent", () => { + expect(decodeProjectName("C--Users-info-Documents-git-pi-zero-hw-management")).toBe( + "pi-zero-hw-management", + ); + }); + + it("extracts after -src-", () => { + expect(decodeProjectName("-home-user-src-my-project")).toBe("my-project"); + }); + + it("extracts after -Documents-", () => { + expect(decodeProjectName("-Users-foo-Documents-cool-app")).toBe("cool-app"); + }); + + it("returns full encoded dir when no known parent found", () => { + expect(decodeProjectName("-some-unusual-path-project")).toBe("-some-unusual-path-project"); + }); +}); diff --git a/src/parsers/claudeLocator.ts b/src/parsers/claudeLocator.ts index 02e91b7..0109660 100644 --- a/src/parsers/claudeLocator.ts +++ b/src/parsers/claudeLocator.ts @@ -3,6 +3,11 @@ import * as path from "node:path"; import * as os from "node:os"; import { getLogger } from "../logger.js"; +/** Yield control to the event loop to prevent extension host unresponsiveness */ +function yieldEventLoop(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + export interface ClaudeSessionEntry { sessionId: string; fullPath: string; @@ -12,6 +17,8 @@ export interface ClaudeSessionEntry { modified: string; gitBranch: string; subagentPaths: string[]; + projectName?: string; + isCurrentWorkspace?: boolean; } /** @@ -19,7 +26,7 @@ export interface ClaudeSessionEntry { * `/Users/peterbru/project` → `-Users-peterbru-project` */ export function encodeProjectPath(workspacePath: string): string { - return workspacePath.replace(/\/+$/, "").replace(/\//g, "-"); + return workspacePath.replace(/[/\\]+$/, "").replace(/:/g, "-").replace(/[/\\]/g, "-"); } /** @@ -250,19 +257,20 @@ async function scanSubdirsByFolderName( return allSessions; } -async function scanProjectDir( +export async function scanProjectDir( projectDir: string, ): Promise { const log = getLogger(); // Try sessions-index.json first const indexPath = path.join(projectDir, "sessions-index.json"); + const indexedIds = new Set(); + const verified: ClaudeSessionEntry[] = []; try { const raw = await fs.readFile(indexPath, "utf-8"); const entries = parseSessionIndex(raw); if (entries.length > 0) { log.debug(` Found ${entries.length} session(s) in index at ${projectDir}`); - const verified: ClaudeSessionEntry[] = []; for (const entry of entries) { try { await fs.access(entry.fullPath); @@ -270,28 +278,26 @@ async function scanProjectDir( projectDir, entry.sessionId, ); + indexedIds.add(entry.sessionId); verified.push(entry); } catch { log.warn(` Session file missing: ${entry.fullPath}`); } } - return verified; } } catch { // No index, fall through to scan } - // Fallback: scan for JSONL files + // Always scan for JSONL files not covered by the index try { const files = await fs.readdir(projectDir); const jsonlFiles = files.filter((f) => f.endsWith(".jsonl")); - if (jsonlFiles.length === 0) return []; - log.debug(` Found ${jsonlFiles.length} JSONL file(s) by scan in ${projectDir}`); - const entries: ClaudeSessionEntry[] = []; for (const f of jsonlFiles) { const sessionId = f.replace(/\.jsonl$/, ""); - entries.push({ + if (indexedIds.has(sessionId)) continue; + verified.push({ sessionId, fullPath: path.join(projectDir, f), summary: null, @@ -302,10 +308,107 @@ async function scanProjectDir( subagentPaths: await discoverSubagentFiles(projectDir, sessionId), }); } - return entries; } catch { + // ignore scan errors + } + + return verified; +} + +/** + * Extract a human-readable project name from an encoded Claude project directory name. + * + * Claude encodes workspace paths by replacing `:`, `/`, and `\` with `-`. + * For example `C:\Users\info\Documents\git\agent-lens` becomes + * `C--Users-info-Documents-git-agent-lens`. + * + * Since project names can themselves contain dashes we use known parent directory + * markers to locate the boundary between the parent path and the project name. + * If no known marker is found the full encoded dir is returned unchanged. + */ +export function decodeProjectName(encodedDir: string): string { + const markers = [ + "-git-", + "-src-", + "-repos-", + "-repo-", + "-code-", + "-projects-", + "-Projects-", + "-Documents-", + "-workspaces-", + "-workspace-", + "-repositories-", + "-dev-", + "-work-", + "-home-", + ]; + let bestIdx = -1; + let bestLen = 0; + for (const m of markers) { + const idx = encodedDir.lastIndexOf(m); + if (idx > bestIdx) { + bestIdx = idx; + bestLen = m.length; + } + } + if (bestIdx >= 0) { + return encodedDir.slice(bestIdx + bestLen); + } + return encodedDir; +} + +/** + * Discover Claude Code sessions across ALL projects in `~/.claude/projects/`. + * + * Each returned entry is annotated with: + * - `projectName` — a human-readable name derived from the encoded directory name + * - `isCurrentWorkspace` — true when the encoded directory matches the current workspace path + */ +export async function discoverAllClaudeProjects( + currentWorkspacePath: string | null, +): Promise { + const log = getLogger(); + const claudeRoot = path.join(os.homedir(), ".claude", "projects"); + + let subdirs: string[]; + try { + subdirs = await fs.readdir(claudeRoot); + } catch { + log.debug("Claude global discovery: cannot read projects root"); return []; } + + const currentVariants = currentWorkspacePath + ? new Set(encodedPathVariants(currentWorkspacePath)) + : new Set(); + + const allEntries: ClaudeSessionEntry[] = []; + let dirIndex = 0; + for (const subdir of subdirs) { + // Yield every 10 project directories to prevent blocking the event loop + if (dirIndex > 0 && dirIndex % 10 === 0) { + await yieldEventLoop(); + } + dirIndex++; + const fullPath = path.join(claudeRoot, subdir); + try { + const stat = await fs.stat(fullPath); + if (!stat.isDirectory()) continue; + } catch { + continue; + } + + const entries = await scanProjectDir(fullPath); + const projectName = decodeProjectName(subdir); + for (const entry of entries) { + entry.projectName = projectName; + entry.isCurrentWorkspace = currentVariants.has(subdir); + allEntries.push(entry); + } + } + + return allEntries; } async function discoverSubagentFiles( diff --git a/src/parsers/claudeProvider.ts b/src/parsers/claudeProvider.ts index 7d0fd9f..8c3fc95 100644 --- a/src/parsers/claudeProvider.ts +++ b/src/parsers/claudeProvider.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs/promises"; import { discoverClaudeSessions, discoverClaudeSessionsInDir, + discoverAllClaudeProjects, encodeProjectPath, } from "./claudeLocator.js"; import { @@ -20,59 +21,110 @@ import type { } from "./sessionProvider.js"; import { getLogger } from "../logger.js"; +/** Yield control to the event loop to prevent extension host unresponsiveness */ +function yieldEventLoop(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +interface ClaudeCacheEntry { + mtimeMs: number; + session: Session; +} + export class ClaudeSessionProvider implements SessionProvider { readonly name = "Claude"; + private sessionCache = new Map(); async discoverSessions(ctx: SessionDiscoveryContext): Promise { const log = getLogger(); const { workspacePath } = ctx; - // Scan both user-configured dir and default location, merge results - const configDir = vscode.workspace + const discoverAll = vscode.workspace .getConfiguration("agentLens") - .get("claudeDir"); + .get("discoverAllProjects", true); let entries = []; - // 1. User-configured claudeDir (for devcontainers with mounts) - if (configDir) { - log.debug(`Claude: scanning configured claudeDir = "${configDir}"`); - const configEntries = await discoverClaudeSessionsInDir(configDir, workspacePath); - log.debug(` Found ${configEntries.length} session(s) via configured dir`); - entries.push(...configEntries); - } + if (discoverAll) { + // Global mode: discover sessions from ALL Claude projects + log.debug("Claude: global discovery mode (discoverAllProjects=true)"); + entries = await discoverAllClaudeProjects(workspacePath); + + // Also merge in user-configured claudeDir if set + const configDir = vscode.workspace + .getConfiguration("agentLens") + .get("claudeDir"); + if (configDir) { + log.debug(`Claude: also scanning configured claudeDir = "${configDir}"`); + const configEntries = await discoverClaudeSessionsInDir(configDir, workspacePath); + const seen = new Set(entries.map((e) => e.fullPath)); + for (const entry of configEntries) { + if (!seen.has(entry.fullPath)) { + entries.push(entry); + } + } + } + } else { + // Workspace-only mode: existing behavior + const configDir = vscode.workspace + .getConfiguration("agentLens") + .get("claudeDir"); + + if (configDir) { + log.debug(`Claude: scanning configured claudeDir = "${configDir}"`); + const configEntries = await discoverClaudeSessionsInDir(configDir, workspacePath); + log.debug(` Found ${configEntries.length} session(s) via configured dir`); + entries.push(...configEntries); + } - // 2. Default: ~/.claude/projects/{encoded-path} - if (workspacePath) { - const defaultEntries = await discoverClaudeSessions(workspacePath); - log.debug(` Found ${defaultEntries.length} session(s) via default path`); - // Deduplicate by file path - const seen = new Set(entries.map((e) => e.fullPath)); - for (const entry of defaultEntries) { - if (!seen.has(entry.fullPath)) { - entries.push(entry); + if (workspacePath) { + const defaultEntries = await discoverClaudeSessions(workspacePath); + log.debug(` Found ${defaultEntries.length} session(s) via default path`); + const seen = new Set(entries.map((e) => e.fullPath)); + for (const entry of defaultEntries) { + if (!seen.has(entry.fullPath)) { + entries.push(entry); + } } } - } - if (entries.length === 0) { - if (!workspacePath && !configDir) { - log.debug("Claude: no workspace path and no claudeDir configured, skipping"); + if (entries.length === 0) { + if (!workspacePath && !configDir) { + log.debug("Claude: no workspace path and no claudeDir configured, skipping"); + } + return []; } - return []; } + if (entries.length === 0) return []; + log.debug(`Claude: parsing ${entries.length} session(s)`); const sessions: Session[] = []; + const seenFiles = new Set(); + let entryIndex = 0; for (const entry of entries) { + // Yield every 5 entries to prevent blocking the event loop + if (entryIndex > 0 && entryIndex % 5 === 0) { + await yieldEventLoop(); + } + entryIndex++; + seenFiles.add(entry.fullPath); try { - const content = await fs.readFile(entry.fullPath, "utf-8"); + // Check cache by mtime to skip unchanged files + const stats = await fs.stat(entry.fullPath); + const cached = this.sessionCache.get(entry.fullPath); + if (cached && cached.mtimeMs === stats.mtimeMs) { + // Re-stamp metadata (may change between refreshes) + if (entry.projectName) cached.session.projectName = entry.projectName; + if (entry.isCurrentWorkspace !== undefined) cached.session.isCurrentWorkspace = entry.isCurrentWorkspace; + sessions.push(cached.session); + continue; + } - // Build agentId -> subagentType mapping from main session + const content = await fs.readFile(entry.fullPath, "utf-8"); const typeMap = buildSubagentTypeMap(content); - // Read subagent files const subagents: SubagentInput[] = []; for (const subPath of entry.subagentPaths) { try { @@ -91,11 +143,15 @@ export class ClaudeSessionProvider implements SessionProvider { } } - const session = parseClaudeSessionJsonl( + const session = await parseClaudeSessionJsonl( content, entry.summary, subagents.length > 0 ? subagents : undefined, ); + // Stamp project metadata from the locator entry + if (entry.projectName) session.projectName = entry.projectName; + if (entry.isCurrentWorkspace !== undefined) session.isCurrentWorkspace = entry.isCurrentWorkspace; + this.sessionCache.set(entry.fullPath, { mtimeMs: stats.mtimeMs, session }); sessions.push(session); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -103,6 +159,11 @@ export class ClaudeSessionProvider implements SessionProvider { } } + // Prune stale cache entries for files no longer discovered + for (const key of this.sessionCache.keys()) { + if (!seenFiles.has(key)) this.sessionCache.delete(key); + } + log.debug(`Claude: parsed ${sessions.length} session(s)`); return sessions; } @@ -111,48 +172,68 @@ export class ClaudeSessionProvider implements SessionProvider { const { workspacePath } = ctx; const targets: WatchTarget[] = []; - // Watch user-configured dir if set - const configDir = vscode.workspace + const discoverAll = vscode.workspace .getConfiguration("agentLens") - .get("claudeDir"); - if (configDir) { + .get("discoverAllProjects", true); + + if (discoverAll) { + // Global mode: watch all projects under ~/.claude/projects/ + const claudeProjectsRoot = path.join(os.homedir(), ".claude", "projects"); targets.push( { pattern: new vscode.RelativePattern( - vscode.Uri.file(configDir), + vscode.Uri.file(claudeProjectsRoot), "**/*.jsonl", ), events: ["create", "change"], }, ); - } + } else { + // Workspace-only mode: existing behavior + + // Watch user-configured dir if set + const configDir = vscode.workspace + .getConfiguration("agentLens") + .get("claudeDir"); + if (configDir) { + targets.push( + { + pattern: new vscode.RelativePattern( + vscode.Uri.file(configDir), + "**/*.jsonl", + ), + events: ["create", "change"], + }, + ); + } - // Watch default location - if (workspacePath) { - const encoded = encodeProjectPath(workspacePath); - const claudeProjectDir = path.join( - os.homedir(), - ".claude", - "projects", - encoded, - ); + // Watch default location + if (workspacePath) { + const encoded = encodeProjectPath(workspacePath); + const claudeProjectDir = path.join( + os.homedir(), + ".claude", + "projects", + encoded, + ); - targets.push( - { - pattern: new vscode.RelativePattern( - vscode.Uri.file(claudeProjectDir), - "*.jsonl", - ), - events: ["create", "change"], - }, - { - pattern: new vscode.RelativePattern( - vscode.Uri.file(claudeProjectDir), - "*/subagents/agent-*.jsonl", - ), - events: ["create", "change"], - }, - ); + targets.push( + { + pattern: new vscode.RelativePattern( + vscode.Uri.file(claudeProjectDir), + "*.jsonl", + ), + events: ["create", "change"], + }, + { + pattern: new vscode.RelativePattern( + vscode.Uri.file(claudeProjectDir), + "*/subagents/agent-*.jsonl", + ), + events: ["create", "change"], + }, + ); + } } return targets; diff --git a/src/parsers/claudeSessionParser.test.ts b/src/parsers/claudeSessionParser.test.ts index 635b70f..bb98877 100644 --- a/src/parsers/claudeSessionParser.test.ts +++ b/src/parsers/claudeSessionParser.test.ts @@ -66,8 +66,8 @@ const BASIC_SESSION = [ ].join("\n"); describe("parseClaudeSessionJsonl", () => { - it("parses a basic session with one exchange", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("parses a basic session with one exchange", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); expect(session.sessionId).toBe("sess-1"); expect(session.source).toBe("claude"); @@ -75,28 +75,28 @@ describe("parseClaudeSessionJsonl", () => { expect(session.requests).toHaveLength(1); }); - it("uses summary as title when provided", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, "My Summary"); + it("uses summary as title when provided", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, "My Summary"); expect(session.title).toBe("My Summary"); }); - it("derives title from first user message when no summary", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("derives title from first user message when no summary", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); expect(session.title).toBe("Hello world"); }); - it("truncates long first-message titles to 80 characters", () => { + it("truncates long first-message titles to 80 characters", async () => { const longMessage = "A".repeat(100); const lines = [ userLine(longMessage, "u1"), assistantLine({ uuid: "a1", parentUuid: "u1" }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.title).toBe("A".repeat(80) + "\u2026"); }); - it("skips system-injected tags when deriving title", () => { + it("skips system-injected tags when deriving title", async () => { const lines = [ JSON.stringify({ type: "user", @@ -122,28 +122,28 @@ describe("parseClaudeSessionJsonl", () => { assistantLine({ uuid: "a1", parentUuid: "u1" }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.title).toBe("let's fix the bug in the parser"); }); - it("falls back to null title when no summary and no user messages", () => { + it("falls back to null title when no summary and no user messages", async () => { const lines = [ assistantLine({ uuid: "a1", parentUuid: "u1" }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.title).toBeNull(); }); - it("sets creationDate from first message timestamp", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("sets creationDate from first message timestamp", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); expect(session.creationDate).toBe( new Date("2026-02-14T10:00:00.000Z").getTime(), ); }); - it("extracts request metadata", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("extracts request metadata", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); const req = session.requests[0]; expect(req.requestId).toBe("a1"); @@ -155,15 +155,15 @@ describe("parseClaudeSessionJsonl", () => { ); }); - it("extracts token usage", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("extracts token usage", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); const req = session.requests[0]; expect(req.usage.promptTokens).toBe(100); expect(req.usage.completionTokens).toBe(50); }); - it("extracts tool calls from content blocks", () => { + it("extracts tool calls from content blocks", async () => { const content = [ { type: "text", text: "Let me read that file." }, { type: "tool_use", id: "toolu_1", name: "Read", input: {} }, @@ -175,7 +175,7 @@ describe("parseClaudeSessionJsonl", () => { assistantLine({ uuid: "a1", parentUuid: "u1", content }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests[0].toolCalls).toHaveLength(2); expect(session.requests[0].toolCalls[0]).toEqual({ id: "toolu_1", @@ -187,7 +187,7 @@ describe("parseClaudeSessionJsonl", () => { }); }); - it("aggregates multiple assistant messages into one request per user turn", () => { + it("aggregates multiple assistant messages into one request per user turn", async () => { // Claude often sends multiple assistant lines for a single response // (text, then tool_use, then more text after tool_result) // We treat each assistant line as a separate request @@ -209,7 +209,7 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests).toHaveLength(2); expect(session.requests[0].usage.promptTokens).toBe(100); expect(session.requests[0].messageText).toBe("do something"); @@ -217,7 +217,7 @@ describe("parseClaudeSessionJsonl", () => { expect(session.requests[1].messageText).toBe(""); }); - it("skips sidechain (subagent) messages", () => { + it("skips sidechain (subagent) messages", async () => { const lines = [ userLine("hello", "u1"), assistantLine({ uuid: "a1", parentUuid: "u1" }), @@ -228,28 +228,28 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests).toHaveLength(1); }); - it("skips progress and other non-message lines", () => { + it("skips progress and other non-message lines", async () => { const lines = [ userLine("hi", "u1"), progressLine(), assistantLine({ uuid: "a1", parentUuid: "u1" }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests).toHaveLength(1); }); - it("handles empty content", () => { - const session = parseClaudeSessionJsonl("", null); + it("handles empty content", async () => { + const session = await parseClaudeSessionJsonl("", null); expect(session.sessionId).toBe("unknown"); expect(session.requests).toEqual([]); }); - it("handles malformed lines gracefully", () => { + it("handles malformed lines gracefully", async () => { const lines = [ "not json at all", userLine("hello", "u1"), @@ -257,11 +257,11 @@ describe("parseClaudeSessionJsonl", () => { assistantLine({ uuid: "a1", parentUuid: "u1" }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests).toHaveLength(1); }); - it("handles multiple user-assistant exchanges", () => { + it("handles multiple user-assistant exchanges", async () => { const lines = [ userLine("first question", "u1"), assistantLine({ @@ -277,14 +277,14 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests).toHaveLength(2); expect(session.requests[0].messageText).toBe("first question"); expect(session.requests[1].messageText).toBe("second question"); }); - it("sets empty skills when no Skill tool is used", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("sets empty skills when no Skill tool is used", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); const req = session.requests[0]; expect(req.customAgentName).toBeNull(); @@ -292,7 +292,7 @@ describe("parseClaudeSessionJsonl", () => { expect(req.loadedSkills).toEqual([]); }); - it("detects loaded skills from Skill tool_use blocks", () => { + it("detects loaded skills from Skill tool_use blocks", async () => { const lines = [ userLine("fix bug 48", "u1"), assistantLine({ @@ -305,11 +305,11 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests[0].loadedSkills).toEqual(["bugfix"]); }); - it("detects multiple skills in a single request", () => { + it("detects multiple skills in a single request", async () => { const lines = [ userLine("help me test", "u1"), assistantLine({ @@ -322,11 +322,11 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests[0].loadedSkills).toEqual(["testing", "vscode-extensions"]); }); - it("does not count non-Skill tool_use blocks as skills", () => { + it("does not count non-Skill tool_use blocks as skills", async () => { const lines = [ userLine("read a file", "u1"), assistantLine({ @@ -339,19 +339,19 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests[0].loadedSkills).toEqual([]); }); - it("sets timings to null (not available in Claude format)", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null); + it("sets timings to null (not available in Claude format)", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null); const req = session.requests[0]; expect(req.timings.firstProgress).toBeNull(); expect(req.timings.totalElapsed).toBeNull(); }); - it("extracts cache token usage", () => { + it("extracts cache token usage", async () => { const lines = [ userLine("hello", "u1"), assistantLine({ @@ -366,7 +366,7 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); const req = session.requests[0]; expect(req.usage.promptTokens).toBe(3); @@ -375,7 +375,7 @@ describe("parseClaudeSessionJsonl", () => { expect(req.usage.cacheCreationTokens).toBe(2620); }); - it("defaults cache tokens to 0 when not present", () => { + it("defaults cache tokens to 0 when not present", async () => { const lines = [ userLine("hello", "u1"), assistantLine({ @@ -385,14 +385,14 @@ describe("parseClaudeSessionJsonl", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); const req = session.requests[0]; expect(req.usage.cacheReadTokens).toBe(0); expect(req.usage.cacheCreationTokens).toBe(0); }); - it("handles string content gracefully", () => { + it("handles string content gracefully", async () => { const lines = [ JSON.stringify({ type: "user", @@ -404,7 +404,7 @@ describe("parseClaudeSessionJsonl", () => { assistantLine({ uuid: "a1", parentUuid: "u1" }), ].join("\n"); - const session = parseClaudeSessionJsonl(lines, null); + const session = await parseClaudeSessionJsonl(lines, null); expect(session.requests).toHaveLength(1); expect(session.requests[0].messageText).toBe("plain string content"); }); @@ -532,7 +532,7 @@ function subagentAssistantLine(opts: { } describe("buildSubagentTypeMap", () => { - it("extracts agentId to subagentType mapping from Task tool calls", () => { + it("extracts agentId to subagentType mapping from Task tool calls", async () => { const lines = [ userLine("do something", "u1"), taskToolUseLine({ @@ -554,7 +554,7 @@ describe("buildSubagentTypeMap", () => { expect(map.get("abc123")).toBe("Explore"); }); - it("extracts agentId to subagentType mapping from Agent tool calls (renamed from Task)", () => { + it("extracts agentId to subagentType mapping from Agent tool calls (renamed from Task)", async () => { const agentToolUseLine = JSON.stringify({ type: "assistant", sessionId: "sess-1", @@ -596,12 +596,12 @@ describe("buildSubagentTypeMap", () => { expect(map.get("abc123")).toBe("Explore"); }); - it("returns empty map when no Task tool calls", () => { + it("returns empty map when no Task tool calls", async () => { const map = buildSubagentTypeMap(BASIC_SESSION); expect(map.size).toBe(0); }); - it("handles multiple Task invocations", () => { + it("handles multiple Task invocations", async () => { const lines = [ userLine("start", "u1"), taskToolUseLine({ @@ -637,7 +637,7 @@ describe("buildSubagentTypeMap", () => { expect(map.get("def456")).toBe("Plan"); }); - it("handles missing agentId in tool_result gracefully", () => { + it("handles missing agentId in tool_result gracefully", async () => { const lines = [ userLine("start", "u1"), taskToolUseLine({ @@ -672,7 +672,7 @@ describe("buildSubagentTypeMap", () => { expect(map.size).toBe(0); }); - it("uses last agentId match when tool_result contains multiple references", () => { + it("uses last agentId match when tool_result contains multiple references", async () => { // Simulate a tool_result where the Explore agent's output mentions // other agent files, but the real agentId is appended at the end const toolResultWithMultipleAgentIds = JSON.stringify({ @@ -726,7 +726,7 @@ describe("buildSubagentTypeMap", () => { expect(map.has("a605be3")).toBe(false); }); - it("handles agentIds with hyphens", () => { + it("handles agentIds with hyphens", async () => { const lines = [ userLine("start", "u1"), taskToolUseLine({ @@ -750,7 +750,7 @@ describe("buildSubagentTypeMap", () => { }); describe("subagent parsing", () => { - it("includes subagent requests when subagent input provided", () => { + it("includes subagent requests when subagent input provided", async () => { const mainContent = BASIC_SESSION; const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), @@ -761,14 +761,14 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(mainContent, null, [ + const session = await parseClaudeSessionJsonl(mainContent, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); expect(session.requests).toHaveLength(2); // 1 main + 1 subagent }); - it("sets isSubagent true on subagent requests", () => { + it("sets isSubagent true on subagent requests", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), subagentAssistantLine({ @@ -778,7 +778,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); @@ -791,7 +791,7 @@ describe("subagent parsing", () => { expect(subReq!.isSubagent).toBe(true); }); - it("sets customAgentName from subagentType", () => { + it("sets customAgentName from subagentType", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), subagentAssistantLine({ @@ -801,7 +801,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); @@ -810,7 +810,7 @@ describe("subagent parsing", () => { expect(subReq!.agentId).toBe("claude-code:subagent"); }); - it("falls back to agentId when subagentType is null", () => { + it("falls back to agentId when subagentType is null", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), subagentAssistantLine({ @@ -820,7 +820,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: null }, ]); @@ -828,7 +828,7 @@ describe("subagent parsing", () => { expect(subReq!.customAgentName).toBe("subagent"); }); - it("labels acompact-* agents as 'compact' when subagentType is null", () => { + it("labels acompact-* agents as 'compact' when subagentType is null", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "acompact-b8acf4" }), subagentAssistantLine({ @@ -838,7 +838,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "acompact-b8acf4", subagentType: null }, ]); @@ -846,7 +846,7 @@ describe("subagent parsing", () => { expect(subReq!.customAgentName).toBe("compact"); }); - it("interleaves subagent requests by timestamp", () => { + it("interleaves subagent requests by timestamp", async () => { const mainContent = [ userLine("first", "u1"), assistantLine({ @@ -876,7 +876,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(mainContent, null, [ + const session = await parseClaudeSessionJsonl(mainContent, null, [ { content: subContent, agentId: "abc123", subagentType: "Bash" }, ]); @@ -893,7 +893,7 @@ describe("subagent parsing", () => { ); }); - it("extracts tool calls from subagent content", () => { + it("extracts tool calls from subagent content", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), subagentAssistantLine({ @@ -907,7 +907,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); @@ -916,7 +916,7 @@ describe("subagent parsing", () => { expect(subReq!.toolCalls[0]).toEqual({ id: "t1", name: "Read" }); }); - it("extracts token usage from subagent content", () => { + it("extracts token usage from subagent content", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), subagentAssistantLine({ @@ -932,7 +932,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); @@ -943,7 +943,7 @@ describe("subagent parsing", () => { expect(subReq!.usage.cacheCreationTokens).toBe(1000); }); - it("sets subagentId on subagent requests", () => { + it("sets subagentId on subagent requests", async () => { const subContent = [ subagentUserLine({ uuid: "su1", agentId: "abc123" }), subagentAssistantLine({ @@ -953,7 +953,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); @@ -961,7 +961,7 @@ describe("subagent parsing", () => { expect(subReq!.subagentId).toBe("abc123"); }); - it("sets parentRequestId linking subagent to spawning main request", () => { + it("sets parentRequestId linking subagent to spawning main request", async () => { // Main session: user asks → assistant spawns Agent tool → tool_result returns agentId const mainContent = [ userLine("do something", "u1"), @@ -996,7 +996,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(mainContent, null, [ + const session = await parseClaudeSessionJsonl(mainContent, null, [ { content: subContent, agentId: "abc123", subagentType: "Explore" }, ]); @@ -1005,7 +1005,7 @@ describe("subagent parsing", () => { expect(subReq!.parentRequestId).toBe("a1"); }); - it("links multiple subagents to their respective parent requests", () => { + it("links multiple subagents to their respective parent requests", async () => { const mainContent = [ userLine("start", "u1"), taskToolUseLine({ @@ -1068,7 +1068,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(mainContent, null, [ + const session = await parseClaudeSessionJsonl(mainContent, null, [ { content: subContent1, agentId: "abc123", subagentType: "Explore" }, { content: subContent2, agentId: "def456", subagentType: "Plan" }, ]); @@ -1084,7 +1084,7 @@ describe("subagent parsing", () => { expect(planReq!.parentRequestId).toBe("a2"); }); - it("detects preloaded skills from skill-format tags in subagent user messages", () => { + it("detects preloaded skills from skill-format tags in subagent user messages", async () => { const subContent = [ // Task prompt JSON.stringify({ @@ -1136,7 +1136,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: subContent, agentId: "abc123", subagentType: "Implementer" }, ]); @@ -1145,8 +1145,8 @@ describe("subagent parsing", () => { expect(subReq!.loadedSkills).toContain("testing"); }); - it("handles empty subagent content gracefully", () => { - const session = parseClaudeSessionJsonl(BASIC_SESSION, null, [ + it("handles empty subagent content gracefully", async () => { + const session = await parseClaudeSessionJsonl(BASIC_SESSION, null, [ { content: "", agentId: "abc123", subagentType: "Explore" }, ]); @@ -1155,7 +1155,7 @@ describe("subagent parsing", () => { expect(session.requests[0].isSubagent).toBeFalsy(); }); - it("still skips isSidechain lines in main session content", () => { + it("still skips isSidechain lines in main session content", async () => { const mainContent = [ userLine("hello", "u1"), assistantLine({ uuid: "a1", parentUuid: "u1" }), @@ -1166,7 +1166,7 @@ describe("subagent parsing", () => { }), ].join("\n"); - const session = parseClaudeSessionJsonl(mainContent, null); + const session = await parseClaudeSessionJsonl(mainContent, null); expect(session.requests).toHaveLength(1); }); }); diff --git a/src/parsers/claudeSessionParser.ts b/src/parsers/claudeSessionParser.ts index ddb0731..96ba9c5 100644 --- a/src/parsers/claudeSessionParser.ts +++ b/src/parsers/claudeSessionParser.ts @@ -162,11 +162,11 @@ function extractToolResultText(block: ContentBlock): string { * When `subagents` is provided, their assistant messages are parsed * and interleaved into the timeline by timestamp. */ -export function parseClaudeSessionJsonl( +export async function parseClaudeSessionJsonl( content: string, summary: string | null, subagents?: SubagentInput[], -): Session { +): Promise { if (!content.trim()) { return { sessionId: "unknown", @@ -186,10 +186,15 @@ export function parseClaudeSessionJsonl( let firstUserText: string | null = null; const requests: SessionRequest[] = []; - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + // Yield every 500 lines to prevent blocking the event loop + if (i > 0 && i % 500 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + let parsed: ClaudeLine; try { - parsed = JSON.parse(line); + parsed = JSON.parse(lines[i]); } catch { continue; } diff --git a/src/parsers/copilotProvider.test.ts b/src/parsers/copilotProvider.test.ts index a5235a9..d0fbd89 100644 --- a/src/parsers/copilotProvider.test.ts +++ b/src/parsers/copilotProvider.test.ts @@ -7,6 +7,7 @@ vi.mock("node:fs/promises", () => ({ readdir: vi.fn(), readFile: vi.fn(), access: vi.fn(), + stat: vi.fn(), })); vi.mock("vscode", () => ({ @@ -23,6 +24,7 @@ vi.mock("vscode", () => ({ const mockReaddir = vi.mocked(fs.readdir); const mockReadFile = vi.mocked(fs.readFile); +const mockStat = vi.mocked(fs.stat); // We test the internal helpers through their effects on the public // discoverSessions() method, using a minimal fake ExtensionContext. @@ -67,10 +69,18 @@ function minimalSession(id: string) { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue(null) } as any); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultVal?: unknown) => { + if (key === "discoverAllProjects") return false; + return defaultVal ?? null; + }), + } as any); vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(undefined as any); mockReadFile.mockRejectedValue(new Error("ENOENT")); mockReaddir.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })); + // Default: every stat returns a unique mtime so cache always misses + let mtimeCounter = 0; + mockStat.mockImplementation(async () => ({ mtimeMs: ++mtimeCounter }) as any); }); describe("CopilotSessionProvider — primary hash (strategy 2)", () => { @@ -128,18 +138,18 @@ describe("CopilotSessionProvider — stale hash fallback (strategy 3)", () => { const currentChatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); mockReaddir.mockImplementation(async (p) => { - const ps = String(p); - if (ps === currentChatDir) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + const ps = String(p).replace(/\\/g, "/"); + if (ps === currentChatDir.replace(/\\/g, "/")) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); if (ps === STORAGE_ROOT) return [CURRENT_HASH, STALE_HASH, "unrelated-hash"] as any; - if (ps === staleChatDir) return ["session-xyz.jsonl"] as any; + if (ps === staleChatDir.replace(/\\/g, "/")) return ["session-xyz.jsonl"] as any; throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); mockReadFile.mockImplementation(async (p) => { - const ps = String(p); - if (ps === path.join(staleHashDir, "workspace.json")) { + const ps = String(p).replace(/\\/g, "/"); + if (ps === path.join(staleHashDir, "workspace.json").replace(/\\/g, "/")) { return JSON.stringify({ folder: WORKSPACE_URI }); } - if (ps === path.join(STORAGE_ROOT, CURRENT_HASH, "workspace.json")) { + if (ps === path.join(STORAGE_ROOT, CURRENT_HASH, "workspace.json").replace(/\\/g, "/")) { return JSON.stringify({ folder: WORKSPACE_URI }); } if (ps.includes("unrelated-hash") && ps.endsWith("workspace.json")) { @@ -164,10 +174,10 @@ describe("CopilotSessionProvider — stale hash fallback (strategy 3)", () => { const currentChatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); mockReaddir.mockImplementation(async (p) => { - const ps = String(p); - if (ps === currentChatDir) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + const ps = String(p).replace(/\\/g, "/"); + if (ps === currentChatDir.replace(/\\/g, "/")) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); if (ps === STORAGE_ROOT) return [CURRENT_HASH, STALE_HASH] as any; - if (ps === staleChatDir) return ["session-xyz.jsonl"] as any; + if (ps === staleChatDir.replace(/\\/g, "/")) return ["session-xyz.jsonl"] as any; throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); mockReadFile.mockImplementation(async (p) => { @@ -194,11 +204,11 @@ describe("CopilotSessionProvider — stale hash fallback (strategy 3)", () => { const currentChatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); mockReaddir.mockImplementation(async (p) => { - const ps = String(p); - if (ps === currentChatDir) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + const ps = String(p).replace(/\\/g, "/"); + if (ps === currentChatDir.replace(/\\/g, "/")) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); if (ps === STORAGE_ROOT) return [CURRENT_HASH, STALE_HASH, staleHash2] as any; - if (ps === stale1ChatDir) return ["session-dup.jsonl", "session-unique1.jsonl"] as any; - if (ps === stale2ChatDir) return ["session-dup.jsonl", "session-unique2.jsonl"] as any; + if (ps === stale1ChatDir.replace(/\\/g, "/")) return ["session-dup.jsonl", "session-unique1.jsonl"] as any; + if (ps === stale2ChatDir.replace(/\\/g, "/")) return ["session-dup.jsonl", "session-unique2.jsonl"] as any; throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); }); mockReadFile.mockImplementation(async (p) => { @@ -347,7 +357,11 @@ describe("CopilotSessionProvider — accumulative discovery", () => { const currentChatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn().mockReturnValue(configDir), + get: vi.fn((key: string, defaultVal?: unknown) => { + if (key === "discoverAllProjects") return false; + if (key === "sessionDir") return configDir; + return defaultVal ?? null; + }), } as any); mockReaddir.mockImplementation(async (p) => { @@ -381,7 +395,11 @@ describe("CopilotSessionProvider — accumulative discovery", () => { const currentChatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ - get: vi.fn().mockReturnValue(configDir), + get: vi.fn((key: string, defaultVal?: unknown) => { + if (key === "discoverAllProjects") return false; + if (key === "sessionDir") return configDir; + return defaultVal ?? null; + }), } as any); mockReaddir.mockImplementation(async (p) => { @@ -416,3 +434,206 @@ describe("getPlatformStorageRoot", () => { } }); }); + +describe("CopilotSessionProvider — global discovery (discoverAllProjects)", () => { + beforeEach(() => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultVal?: unknown) => { + if (key === "discoverAllProjects") return true; + return defaultVal ?? null; + }), + } as any); + }); + + it("discovers sessions from all workspace storage hash dirs", async () => { + const hash1 = "aaaa1111"; + const hash2 = "bbbb2222"; + const chat1 = path.join(STORAGE_ROOT, hash1, "chatSessions"); + const chat2 = path.join(STORAGE_ROOT, hash2, "chatSessions"); + + mockReaddir.mockImplementation(async (p) => { + const ps = String(p).replace(/\\/g, "/"); + if (ps === STORAGE_ROOT) return [hash1, hash2] as any; + if (ps === chat1.replace(/\\/g, "/")) return ["session-a.jsonl"] as any; + if (ps === chat2.replace(/\\/g, "/")) return ["session-b.jsonl"] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + mockReadFile.mockImplementation(async (p) => { + const ps = String(p).replace(/\\/g, "/"); + if (ps.endsWith("workspace.json") && ps.includes(hash1)) { + return JSON.stringify({ folder: `file:///workspaces/${WORKSPACE_NAME}` }); + } + if (ps.endsWith("workspace.json") && ps.includes(hash2)) { + return JSON.stringify({ folder: "file:///workspaces/other-project" }); + } + if (ps.endsWith("session-a.jsonl")) return minimalSession("session-a"); + if (ps.endsWith("session-b.jsonl")) return minimalSession("session-b"); + throw new Error("ENOENT"); + }); + + const provider = new CopilotSessionProvider(); + const sessions = await provider.discoverSessions(makeCtx()); + + expect(sessions).toHaveLength(2); + const ids = sessions.map((s) => s.sessionId).sort(); + expect(ids).toEqual(["session-a", "session-b"]); + + const currentSession = sessions.find((s) => s.sessionId === "session-a"); + expect(currentSession?.projectName).toBe(WORKSPACE_NAME); + expect(currentSession?.isCurrentWorkspace).toBe(true); + + const otherSession = sessions.find((s) => s.sessionId === "session-b"); + expect(otherSession?.projectName).toBe("other-project"); + expect(otherSession?.isCurrentWorkspace).toBe(false); + }); + + it("falls back to workspace-only discovery when setting is false", async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, defaultVal?: unknown) => { + if (key === "discoverAllProjects") return false; + return defaultVal ?? null; + }), + } as any); + + const currentChatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); + + mockReaddir.mockImplementation(async (p) => { + const ps = String(p).replace(/\\/g, "/"); + if (ps === currentChatDir.replace(/\\/g, "/")) return ["session-abc.jsonl"] as any; + if (ps === STORAGE_ROOT) return [CURRENT_HASH] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + mockReadFile.mockImplementation(async (p) => { + const ps = String(p); + if (ps.endsWith("workspace.json")) return JSON.stringify({ folder: WORKSPACE_URI }); + if (ps.endsWith("session-abc.jsonl")) return minimalSession("session-abc"); + throw new Error("ENOENT"); + }); + + const provider = new CopilotSessionProvider(); + const sessions = await provider.discoverSessions(makeCtx()); + + // Should still work with existing behavior + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe("session-abc"); + }); +}); + +describe("CopilotSessionProvider — session file cache", () => { + it("skips readFile on second call when mtime is unchanged (cache hit)", async () => { + const chatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); + const sessionFile = path.join(chatDir, "session-cached.jsonl"); + + mockReaddir.mockImplementation(async (p) => { + if (String(p) === chatDir) return ["session-cached.jsonl"] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + mockReadFile.mockImplementation(async (p) => { + const ps = String(p); + if (ps.endsWith("workspace.json")) return JSON.stringify({ folder: WORKSPACE_URI }); + if (ps.endsWith("session-cached.jsonl")) return minimalSession("session-cached"); + throw new Error("ENOENT"); + }); + // Return a stable mtime for the session file + mockStat.mockImplementation(async (p) => { + if (String(p) === sessionFile) return { mtimeMs: 1000 } as any; + return { mtimeMs: Date.now() } as any; + }); + + const provider = new CopilotSessionProvider(); + + // First call — cache miss, reads the file + await provider.discoverSessions(makeCtx()); + const readFileCallsAfterFirst = mockReadFile.mock.calls.filter( + (c) => String(c[0]).endsWith("session-cached.jsonl"), + ).length; + expect(readFileCallsAfterFirst).toBe(1); + + // Second call — cache hit, should NOT read the file again + const sessions = await provider.discoverSessions(makeCtx()); + const readFileCallsAfterSecond = mockReadFile.mock.calls.filter( + (c) => String(c[0]).endsWith("session-cached.jsonl"), + ).length; + expect(readFileCallsAfterSecond).toBe(1); // still 1, not 2 + expect(sessions).toHaveLength(1); + expect(sessions[0].sessionId).toBe("session-cached"); + }); + + it("re-reads file when mtime changes (cache invalidation)", async () => { + const chatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); + const sessionFile = path.join(chatDir, "session-inv.jsonl"); + + mockReaddir.mockImplementation(async (p) => { + if (String(p) === chatDir) return ["session-inv.jsonl"] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + mockReadFile.mockImplementation(async (p) => { + const ps = String(p); + if (ps.endsWith("workspace.json")) return JSON.stringify({ folder: WORKSPACE_URI }); + if (ps.endsWith("session-inv.jsonl")) return minimalSession("session-inv"); + throw new Error("ENOENT"); + }); + + let mtime = 1000; + mockStat.mockImplementation(async (p) => { + if (String(p) === sessionFile) return { mtimeMs: mtime } as any; + return { mtimeMs: Date.now() } as any; + }); + + const provider = new CopilotSessionProvider(); + + // First call + await provider.discoverSessions(makeCtx()); + + // Change mtime to simulate file modification + mtime = 2000; + + // Second call — cache invalidated, should re-read + await provider.discoverSessions(makeCtx()); + const readFileCalls = mockReadFile.mock.calls.filter( + (c) => String(c[0]).endsWith("session-inv.jsonl"), + ).length; + expect(readFileCalls).toBe(2); + }); + + it("prunes stale cache entries when files are deleted", async () => { + const chatDir = path.join(STORAGE_ROOT, CURRENT_HASH, "chatSessions"); + + mockReadFile.mockImplementation(async (p) => { + const ps = String(p); + if (ps.endsWith("workspace.json")) return JSON.stringify({ folder: WORKSPACE_URI }); + if (ps.endsWith("session-keep.jsonl")) return minimalSession("session-keep"); + if (ps.endsWith("session-gone.jsonl")) return minimalSession("session-gone"); + throw new Error("ENOENT"); + }); + mockStat.mockImplementation(async () => ({ mtimeMs: 1000 }) as any); + + const provider = new CopilotSessionProvider(); + + // First call: two files present + mockReaddir.mockImplementation(async (p) => { + if (String(p) === chatDir) return ["session-keep.jsonl", "session-gone.jsonl"] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + const first = await provider.discoverSessions(makeCtx()); + expect(first).toHaveLength(2); + + // Second call: one file removed + mockReaddir.mockImplementation(async (p) => { + if (String(p) === chatDir) return ["session-keep.jsonl"] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + const second = await provider.discoverSessions(makeCtx()); + expect(second).toHaveLength(1); + expect(second[0].sessionId).toBe("session-keep"); + + // The removed file should not appear even if readdir somehow includes it again + // (verify cache was pruned, not that the old cached session leaks back) + mockReaddir.mockImplementation(async (p) => { + if (String(p) === chatDir) return ["session-keep.jsonl"] as any; + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + const third = await provider.discoverSessions(makeCtx()); + expect(third).toHaveLength(1); + }); +}); diff --git a/src/parsers/copilotProvider.ts b/src/parsers/copilotProvider.ts index 7c57ae3..b159ada 100644 --- a/src/parsers/copilotProvider.ts +++ b/src/parsers/copilotProvider.ts @@ -11,10 +11,28 @@ import type { import { getLogger } from "../logger.js"; import { getPlatformStorageRoot } from "./platformStorage.js"; +/** Yield control to the event loop to prevent extension host unresponsiveness */ +function yieldEventLoop(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +/** Skip session files larger than this to avoid blocking the event loop on huge files */ +const MAX_SESSION_FILE_BYTES = 15 * 1024 * 1024; // 15 MB + +interface SessionCacheEntry { + mtimeMs: number; + session: Session; +} + /** * Read all session files from a single chatSessions directory. + * Optionally uses a cache to skip unchanged files (by mtime). */ -async function readSessionsFromDir(dir: string): Promise { +async function readSessionsFromDir( + dir: string, + cache?: Map, + seenFiles?: Set, +): Promise { let entries: string[]; try { entries = await fs.readdir(dir); @@ -31,12 +49,31 @@ async function readSessionsFromDir(dir: string): Promise { const sessions: Session[] = []; let emptyCount = 0; + let fileIndex = 0; for (const entry of entries) { if (!entry.endsWith(".jsonl") && !entry.endsWith(".json")) continue; const filePath = path.join(dir, entry); + seenFiles?.add(filePath); + // Yield every 5 files to prevent blocking the event loop + if (fileIndex > 0 && fileIndex % 5 === 0) { + await yieldEventLoop(); + } + fileIndex++; try { + const stats = await fs.stat(filePath); + if (stats.size > MAX_SESSION_FILE_BYTES) { + getLogger().debug(` Skipping oversized session file (${Math.round(stats.size / 1024 / 1024)} MB): "${filePath}"`); + continue; + } + const cached = cache?.get(filePath); + if (cached && cached.mtimeMs === stats.mtimeMs) { + sessions.push(cached.session); + if (cached.session.requests.length === 0) emptyCount++; + continue; + } const content = await fs.readFile(filePath, "utf-8"); - const session = parseSessionJsonl(content); + const session = await parseSessionJsonl(content); + cache?.set(filePath, { mtimeMs: stats.mtimeMs, session }); if (session.requests.length === 0) { emptyCount++; } @@ -123,11 +160,13 @@ async function scanWorkspaceStorageRoot( async function collectFromMatches( matches: ChatSessionsMatch[], scope: "workspace" | "fallback", + cache?: Map, + seenFiles?: Set, ): Promise { const seen = new Set(); const sessions: Session[] = []; for (const { dir, workspaceUri } of matches) { - const found = await readSessionsFromDir(dir); + const found = await readSessionsFromDir(dir, cache, seenFiles); for (const s of found) { if (!seen.has(s.sessionId)) { seen.add(s.sessionId); @@ -140,6 +179,61 @@ async function collectFromMatches( return sessions; } +/** + * Scan ALL hash directories under a workspaceStorage root, returning sessions + * from every workspace (not filtered). Each session gets projectName and + * isCurrentWorkspace stamped. + */ +async function scanAllWorkspaceStorageDirs( + workspaceStorageRoot: string, + currentWorkspaceName: string | null, + cache?: Map, + seenFiles?: Set, +): Promise { + const log = getLogger(); + let hashDirs: string[]; + try { + hashDirs = await fs.readdir(workspaceStorageRoot); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(` Cannot read storage root "${workspaceStorageRoot}": ${msg}`); + return []; + } + + log.debug(` Scanning ${hashDirs.length} hash dir(s) globally`); + const seen = new Set(); + const sessions: Session[] = []; + + let dirIndex = 0; + for (const entry of hashDirs) { + // Yield every 10 directories to prevent blocking the event loop + if (dirIndex > 0 && dirIndex % 10 === 0) { + await yieldEventLoop(); + } + dirIndex++; + const candidateDir = path.join(workspaceStorageRoot, entry); + const candidateUri = await readWorkspaceUri(candidateDir); + if (!candidateUri) continue; + + const chatDir = path.join(candidateDir, "chatSessions"); + const found = await readSessionsFromDir(chatDir, cache, seenFiles); + const name = workspaceName(candidateUri); + const isCurrent = currentWorkspaceName !== null && name === currentWorkspaceName; + + for (const s of found) { + if (seen.has(s.sessionId)) continue; + seen.add(s.sessionId); + s.projectName = name; + s.isCurrentWorkspace = isCurrent; + s.scope = "workspace"; + s.matchedWorkspace = candidateUri; + sessions.push(s); + } + } + + return sessions; +} + /** * Get the current workspace's folder name for matching. */ @@ -191,6 +285,7 @@ function mergeInto( export class CopilotSessionProvider implements SessionProvider { readonly name = "Copilot"; + private sessionCache = new Map(); /** Overridable for testing. */ protected getPlatformStorageRoot(): string | null { @@ -203,23 +298,32 @@ export class CopilotSessionProvider implements SessionProvider { const ourName = await getWorkspaceFolderName(extensionContext); log.debug(`Copilot session discovery: workspace name = "${ourName ?? "(unknown)"}"`); + const discoverAll = vscode.workspace + .getConfiguration("agentLens") + .get("discoverAllProjects", true) ?? true; + + if (discoverAll) { + return this.discoverAllSessions(ctx, ourName); + } + const pool: Session[] = []; const seen = new Set(); + const seenFiles = new Set(); - // ── Strategy 1: user-configured sessionDir (complements, not overrides) ── + // -- Strategy 1: user-configured sessionDir (complements, not overrides) -- const configDir = vscode.workspace .getConfiguration("agentLens") .get("sessionDir"); if (configDir) { log.debug(`Copilot strategy 1: user-configured sessionDir = "${configDir}"`); - const direct = await readSessionsFromDir(configDir); + const direct = await readSessionsFromDir(configDir, this.sessionCache, seenFiles); if (direct.length > 0) { log.debug(` Found ${direct.length} session(s) directly`); mergeInto(pool, seen, direct); } else if (ourName) { const matches = await scanWorkspaceStorageRoot(configDir, ourName); - const scanned = await collectFromMatches(matches, "workspace"); + const scanned = await collectFromMatches(matches, "workspace", this.sessionCache, seenFiles); if (scanned.length > 0) { log.debug(` Found ${scanned.length} session(s) via storage root scan`); mergeInto(pool, seen, scanned); @@ -230,11 +334,11 @@ export class CopilotSessionProvider implements SessionProvider { } } - // ── Strategy 2: primary location (current workspace hash) ── + // -- Strategy 2: primary location (current workspace hash) -- const primaryDir = getChatSessionsDir(extensionContext); if (primaryDir) { log.debug(`Copilot strategy 2: primary dir = "${primaryDir}"`); - const primary = await readSessionsFromDir(primaryDir); + const primary = await readSessionsFromDir(primaryDir, this.sessionCache, seenFiles); if (primary.length > 0) { log.debug(` Found ${primary.length} session(s)`); const hashDir = path.dirname(primaryDir); @@ -251,18 +355,18 @@ export class CopilotSessionProvider implements SessionProvider { log.debug("Copilot strategy 2: skipped (no storageUri)"); } - // ── Strategy 3: sibling hash directories (stale hash recovery) ── + // -- Strategy 3: sibling hash directories (stale hash recovery) -- if (ourName && extensionContext.storageUri) { const hashDir = path.dirname(extensionContext.storageUri.fsPath); const storageRoot = path.dirname(hashDir); log.debug(`Copilot strategy 3: scanning sibling dirs under "${storageRoot}"`); const matches = await scanWorkspaceStorageRoot(storageRoot, ourName); - const result = await collectFromMatches(matches, "fallback"); + const result = await collectFromMatches(matches, "fallback", this.sessionCache, seenFiles); const added = mergeInto(pool, seen, result); log.debug(` Found ${added} new session(s) via sibling scan (stale hash)`); } - // ── Strategy 4: platform app storage root ── + // -- Strategy 4: platform app storage root -- if (ourName) { const platformRoot = this.getPlatformStorageRoot(); // Only probe if it's different from the storage root already scanned in strategy 3 @@ -273,7 +377,7 @@ export class CopilotSessionProvider implements SessionProvider { if (platformRoot && platformRoot !== currentStorageRoot) { log.debug(`Copilot strategy 4: platform storage root = "${platformRoot}"`); const matches = await scanWorkspaceStorageRoot(platformRoot, ourName); - const result = await collectFromMatches(matches, "workspace"); + const result = await collectFromMatches(matches, "workspace", this.sessionCache, seenFiles); const added = mergeInto(pool, seen, result); log.debug(` Found ${added} new session(s) via platform storage root`); } else if (platformRoot) { @@ -283,12 +387,96 @@ export class CopilotSessionProvider implements SessionProvider { } } + // Prune stale cache entries for files no longer discovered + for (const key of this.sessionCache.keys()) { + if (!seenFiles.has(key)) this.sessionCache.delete(key); + } + log.debug(`Copilot: total ${pool.length} session(s) discovered`); return pool; } + private async discoverAllSessions( + ctx: SessionDiscoveryContext, + currentWorkspaceName: string | null, + ): Promise { + const log = getLogger(); + const { extensionContext } = ctx; + const pool: Session[] = []; + const seen = new Set(); + const seenFiles = new Set(); + + // Strategy 1: user-configured sessionDir -- scan all workspaces in it + const configDir = vscode.workspace + .getConfiguration("agentLens") + .get("sessionDir"); + if (configDir) { + log.debug(`Copilot global: scanning configured sessionDir = "${configDir}"`); + // Try as a direct chatSessions dir first + const direct = await readSessionsFromDir(configDir, this.sessionCache, seenFiles); + if (direct.length > 0) { + for (const s of direct) { + s.projectName = currentWorkspaceName ?? undefined; + s.isCurrentWorkspace = true; + } + mergeInto(pool, seen, direct); + } else { + // Scan as workspaceStorage root (all workspaces) + const allFromConfig = await scanAllWorkspaceStorageDirs(configDir, currentWorkspaceName, this.sessionCache, seenFiles); + mergeInto(pool, seen, allFromConfig); + } + } + + // Strategy 2: extension context storage root -- scan all hash dirs + if (extensionContext.storageUri) { + const hashDir = path.dirname(extensionContext.storageUri.fsPath); + const storageRoot = path.dirname(hashDir); + log.debug(`Copilot global: scanning storage root = "${storageRoot}"`); + const allFromRoot = await scanAllWorkspaceStorageDirs(storageRoot, currentWorkspaceName, this.sessionCache, seenFiles); + mergeInto(pool, seen, allFromRoot); + } + + // Strategy 3: platform storage root (if different) + const platformRoot = this.getPlatformStorageRoot(); + const currentStorageRoot = extensionContext.storageUri + ? path.dirname(path.dirname(extensionContext.storageUri.fsPath)) + : null; + if (platformRoot && platformRoot !== currentStorageRoot) { + log.debug(`Copilot global: scanning platform root = "${platformRoot}"`); + const allFromPlatform = await scanAllWorkspaceStorageDirs(platformRoot, currentWorkspaceName, this.sessionCache, seenFiles); + mergeInto(pool, seen, allFromPlatform); + } + + // Prune stale cache entries for files no longer discovered + for (const key of this.sessionCache.keys()) { + if (!seenFiles.has(key)) this.sessionCache.delete(key); + } + + log.debug(`Copilot global: total ${pool.length} session(s) discovered`); + return pool; + } + getWatchTargets(ctx: SessionDiscoveryContext): WatchTarget[] { const storageUri = ctx.extensionContext.storageUri; + + const discoverAll = vscode.workspace + .getConfiguration("agentLens") + .get("discoverAllProjects", true) ?? true; + + if (discoverAll && storageUri) { + const hashDir = path.dirname(storageUri.fsPath); + const storageRoot = path.dirname(hashDir); + return [ + { + pattern: new vscode.RelativePattern( + vscode.Uri.file(storageRoot), + "*/chatSessions/*.jsonl", + ), + events: ["create", "change"], + }, + ]; + } + if (!storageUri) return []; // Anchor watcher to the workspace hash dir (parent of chatSessions/) diff --git a/src/parsers/sessionParser.test.ts b/src/parsers/sessionParser.test.ts index 03a3e79..dadc069 100644 --- a/src/parsers/sessionParser.test.ts +++ b/src/parsers/sessionParser.test.ts @@ -65,8 +65,8 @@ const JSONL_FIXTURE = [ ].join("\n"); describe("parseSessionJsonl", () => { - it("reconstructs a session from JSONL lines", () => { - const session = parseSessionJsonl(JSONL_FIXTURE); + it("reconstructs a session from JSONL lines", async () => { + const session = await parseSessionJsonl(JSONL_FIXTURE); expect(session.sessionId).toBe("test-session-id"); expect(session.title).toBe("Test Session Title"); @@ -76,8 +76,8 @@ describe("parseSessionJsonl", () => { expect(session.requests).toHaveLength(1); }); - it("extracts request metadata", () => { - const session = parseSessionJsonl(JSONL_FIXTURE); + it("extracts request metadata", async () => { + const session = await parseSessionJsonl(JSONL_FIXTURE); const req = session.requests[0]; expect(req.requestId).toBe("req-1"); @@ -87,8 +87,8 @@ describe("parseSessionJsonl", () => { expect(req.messageText).toBe("Hello world"); }); - it("extracts timings and usage", () => { - const session = parseSessionJsonl(JSONL_FIXTURE); + it("extracts timings and usage", async () => { + const session = await parseSessionJsonl(JSONL_FIXTURE); const req = session.requests[0]; expect(req.timings.firstProgress).toBe(2000); @@ -97,8 +97,8 @@ describe("parseSessionJsonl", () => { expect(req.usage.completionTokens).toBe(200); }); - it("extracts tool calls", () => { - const session = parseSessionJsonl(JSONL_FIXTURE); + it("extracts tool calls", async () => { + const session = await parseSessionJsonl(JSONL_FIXTURE); const req = session.requests[0]; expect(req.toolCalls).toHaveLength(2); @@ -106,24 +106,24 @@ describe("parseSessionJsonl", () => { expect(req.toolCalls[1].name).toBe("list_dir"); }); - it("detects custom agent from modeInstructions", () => { - const session = parseSessionJsonl(JSONL_FIXTURE); + it("detects custom agent from modeInstructions", async () => { + const session = await parseSessionJsonl(JSONL_FIXTURE); expect(session.requests[0].customAgentName).toBe("Planner"); }); - it("detects available skills", () => { - const session = parseSessionJsonl(JSONL_FIXTURE); + it("detects available skills", async () => { + const session = await parseSessionJsonl(JSONL_FIXTURE); expect(session.requests[0].availableSkills).toHaveLength(1); expect(session.requests[0].availableSkills[0].name).toBe("testing"); }); - it("handles empty JSONL", () => { - const session = parseSessionJsonl(""); + it("handles empty JSONL", async () => { + const session = await parseSessionJsonl(""); expect(session.sessionId).toBe("unknown"); expect(session.requests).toEqual([]); }); - it("falls back to first user message as title when customTitle is absent", () => { + it("falls back to first user message as title when customTitle is absent", async () => { const lines = [ JSON.stringify({ kind: 0, @@ -144,11 +144,11 @@ describe("parseSessionJsonl", () => { }), ].join("\n"); - const session = parseSessionJsonl(lines); + const session = await parseSessionJsonl(lines); expect(session.title).toBe("Help me fix the login bug"); }); - it("truncates long fallback titles to 80 chars", () => { + it("truncates long fallback titles to 80 chars", async () => { const longMsg = "A".repeat(120); const lines = [ JSON.stringify({ @@ -170,11 +170,11 @@ describe("parseSessionJsonl", () => { }), ].join("\n"); - const session = parseSessionJsonl(lines); + const session = await parseSessionJsonl(lines); expect(session.title).toBe("A".repeat(80) + "\u2026"); }); - it("detects custom agent from inputState.mode in initial state", () => { + it("detects custom agent from inputState.mode in initial state", async () => { const lines = [ JSON.stringify({ kind: 0, @@ -205,11 +205,11 @@ describe("parseSessionJsonl", () => { }), ].join("\n"); - const session = parseSessionJsonl(lines); + const session = await parseSessionJsonl(lines); expect(session.requests[0].customAgentName).toBe("planner"); }); - it("tracks mode changes between requests", () => { + it("tracks mode changes between requests", async () => { const lines = [ JSON.stringify({ kind: 0, @@ -284,17 +284,17 @@ describe("parseSessionJsonl", () => { }), ].join("\n"); - const session = parseSessionJsonl(lines); + const session = await parseSessionJsonl(lines); expect(session.requests).toHaveLength(3); expect(session.requests[0].customAgentName).toBe("planner"); expect(session.requests[1].customAgentName).toBe("architect"); expect(session.requests[2].customAgentName).toBe("tester"); }); - it("falls back to modeInstructions when no inputState.mode", () => { + it("falls back to modeInstructions when no inputState.mode", async () => { // The original JSONL_FIXTURE has no inputState.mode but has // renderedUserMessage with modeInstructions — should still detect "Planner" - const session = parseSessionJsonl(JSONL_FIXTURE); + const session = await parseSessionJsonl(JSONL_FIXTURE); expect(session.requests[0].customAgentName).toBe("Planner"); }); }); @@ -487,8 +487,8 @@ const MULTI_SUBAGENT_FIXTURE = [ ].join("\n"); describe("parseSessionJsonl — runSubagent", () => { - it("extracts runSubagent with child tool calls", () => { - const session = parseSessionJsonl(SUBAGENT_FIXTURE); + it("extracts runSubagent with child tool calls", async () => { + const session = await parseSessionJsonl(SUBAGENT_FIXTURE); const req = session.requests[0]; expect(req.toolCalls).toHaveLength(1); @@ -501,15 +501,15 @@ describe("parseSessionJsonl — runSubagent", () => { expect(sa.childToolCalls![2].name).toBe("copilot_readFile"); }); - it("sets subagentDescription from response metadata", () => { - const session = parseSessionJsonl(SUBAGENT_FIXTURE); + it("sets subagentDescription from response metadata", async () => { + const session = await parseSessionJsonl(SUBAGENT_FIXTURE); const sa = session.requests[0].toolCalls[0]; expect(sa.subagentDescription).toBe("Read all .github agent files"); }); - it("groups child tool calls by subAgentInvocationId", () => { - const session = parseSessionJsonl(MULTI_SUBAGENT_FIXTURE); + it("groups child tool calls by subAgentInvocationId", async () => { + const session = await parseSessionJsonl(MULTI_SUBAGENT_FIXTURE); const req = session.requests[0]; expect(req.toolCalls).toHaveLength(2); @@ -519,7 +519,7 @@ describe("parseSessionJsonl — runSubagent", () => { expect(req.toolCalls[1].subagentDescription).toBe("Read skill files"); }); - it("handles runSubagent with no children gracefully", () => { + it("handles runSubagent with no children gracefully", async () => { const fixture = [ JSON.stringify({ kind: 0, @@ -561,7 +561,7 @@ describe("parseSessionJsonl — runSubagent", () => { }), ].join("\n"); - const session = parseSessionJsonl(fixture); + const session = await parseSessionJsonl(fixture); const sa = session.requests[0].toolCalls[0]; expect(sa.name).toBe("runSubagent"); expect(sa.subagentDescription).toBe("Quick check"); @@ -735,8 +735,8 @@ const MCP_SUBAGENT_FIXTURE = [ ].join("\n"); describe("parseSessionJsonl — MCP tools", () => { - it("extracts mcpServer from response source field", () => { - const session = parseSessionJsonl(MCP_FIXTURE); + it("extracts mcpServer from response source field", async () => { + const session = await parseSessionJsonl(MCP_FIXTURE); const req = session.requests[0]; const serena = req.toolCalls.find( (t) => t.name === "mcp_serena_search_for_pattern", @@ -744,8 +744,8 @@ describe("parseSessionJsonl — MCP tools", () => { expect(serena?.mcpServer).toBe("FastMCP"); }); - it("sets mcpServer on all matching tools by name", () => { - const session = parseSessionJsonl(MCP_FIXTURE); + it("sets mcpServer on all matching tools by name", async () => { + const session = await parseSessionJsonl(MCP_FIXTURE); const req = session.requests[0]; const gitkraken = req.toolCalls.find( (t) => t.name === "mcp_gitkraken_bun_git_status", @@ -758,15 +758,15 @@ describe("parseSessionJsonl — MCP tools", () => { expect(findSymbol?.mcpServer).toBe("FastMCP"); }); - it("does not set mcpServer on built-in tools", () => { - const session = parseSessionJsonl(MCP_FIXTURE); + it("does not set mcpServer on built-in tools", async () => { + const session = await parseSessionJsonl(MCP_FIXTURE); const req = session.requests[0]; const readFile = req.toolCalls.find((t) => t.name === "read_file"); expect(readFile?.mcpServer).toBeUndefined(); }); - it("sets mcpServer on subagent child tool calls", () => { - const session = parseSessionJsonl(MCP_SUBAGENT_FIXTURE); + it("sets mcpServer on subagent child tool calls", async () => { + const session = await parseSessionJsonl(MCP_SUBAGENT_FIXTURE); const req = session.requests[0]; const sa = req.toolCalls.find((t) => t.name === "runSubagent"); expect(sa?.childToolCalls).toHaveLength(2); @@ -830,7 +830,7 @@ const CHATREPLAY_FIXTURE = JSON.stringify({ }); describe("parseChatReplay", () => { - it("parses a chatreplay export", () => { + it("parses a chatreplay export", async () => { const session = parseChatReplay(CHATREPLAY_FIXTURE); expect(session.source).toBe("chatreplay"); @@ -838,7 +838,7 @@ describe("parseChatReplay", () => { expect(session.requests).toHaveLength(1); }); - it("extracts request data from chatreplay", () => { + it("extracts request data from chatreplay", async () => { const session = parseChatReplay(CHATREPLAY_FIXTURE); const req = session.requests[0]; @@ -850,12 +850,12 @@ describe("parseChatReplay", () => { expect(req.usage.completionTokens).toBe(300); }); - it("detects custom agent from chatreplay", () => { + it("detects custom agent from chatreplay", async () => { const session = parseChatReplay(CHATREPLAY_FIXTURE); expect(session.requests[0].customAgentName).toBe("Reviewer"); }); - it("detects loaded skills from tool calls", () => { + it("detects loaded skills from tool calls", async () => { const session = parseChatReplay(CHATREPLAY_FIXTURE); expect(session.requests[0].loadedSkills).toContain("testing"); }); diff --git a/src/parsers/sessionParser.ts b/src/parsers/sessionParser.ts index 8306c62..7e88663 100644 --- a/src/parsers/sessionParser.ts +++ b/src/parsers/sessionParser.ts @@ -45,7 +45,7 @@ function appendToArray(obj: Record, path: (string | number)[], } } -export function parseSessionJsonl(content: string): Session { +export async function parseSessionJsonl(content: string): Promise { if (!content.trim()) { return { sessionId: "unknown", @@ -67,10 +67,15 @@ export function parseSessionJsonl(content: string): Session { let currentAgentName: string | null = null; const agentNameByRequest: (string | null)[] = []; - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + // Yield every 500 lines to prevent blocking the event loop + if (i > 0 && i % 500 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + let parsed: JsonlLine; try { - parsed = JSON.parse(line); + parsed = JSON.parse(lines[i]); } catch { continue; } @@ -102,7 +107,7 @@ export function parseSessionJsonl(content: string): Session { case 2: if (parsed.k && Array.isArray(parsed.v)) { if (parsed.k.length === 1 && parsed.k[0] === "requests") { - for (let i = 0; i < parsed.v.length; i++) { + for (let j = 0; j < parsed.v.length; j++) { agentNameByRequest.push(currentAgentName); } } diff --git a/src/views/sessionPanel.ts b/src/views/sessionPanel.ts index d737e46..f396833 100644 --- a/src/views/sessionPanel.ts +++ b/src/views/sessionPanel.ts @@ -9,6 +9,7 @@ export class SessionPanel { private disposed = false; private currentFilter: SourceFilter = "all"; + private currentScope: "all-projects" | "current-project" = "all-projects"; private cachedSessions: Session[] = []; private customAgentNames: string[] = []; @@ -28,6 +29,10 @@ export class SessionPanel { this.currentFilter = msg.provider; this.pushFilteredSessions(); } + if (msg.type === "scope-change") { + this.currentScope = msg.scope; + this.pushFilteredSessions(); + } }); } @@ -72,14 +77,17 @@ export class SessionPanel { } private pushFilteredSessions(): void { - const byProvider = + let filtered = this.currentFilter === "all" ? this.cachedSessions : this.cachedSessions.filter( (s) => s.provider === this.currentFilter, ); - const nonEmpty = byProvider.filter((s) => s.requests.length > 0); - const emptyCount = byProvider.length - nonEmpty.length; + if (this.currentScope === "current-project") { + filtered = filtered.filter((s) => s.isCurrentWorkspace !== false); + } + const nonEmpty = filtered.filter((s) => s.requests.length > 0); + const emptyCount = filtered.length - nonEmpty.length; this.panel.webview.postMessage({ type: "update-sessions", sessions: nonEmpty, diff --git a/webview/session.ts b/webview/session.ts index 4cc6c3a..37cf8ce 100644 --- a/webview/session.ts +++ b/webview/session.ts @@ -11,6 +11,7 @@ declare function acquireVsCodeApi(): { const vscode = acquireVsCodeApi(); type SourceFilter = "all" | "copilot" | "claude" | "codex"; +type ScopeFilter = "all-projects" | "current-project"; interface ToolCallInfo { id: string; @@ -57,6 +58,8 @@ interface Session { scope?: "workspace" | "fallback"; matchedWorkspace?: string; environment?: string | null; + projectName?: string; + isCurrentWorkspace?: boolean; } @customElement("session-explorer") @@ -375,6 +378,38 @@ class SessionExplorer extends LitElement { background: var(--vscode-button-background, #0e639c); color: var(--vscode-button-foreground, #fff); } + .scope-toggle { + display: inline-flex; + border: 1px solid var(--vscode-editorWidget-border, #454545); + border-radius: 4px; + overflow: hidden; + margin-bottom: 16px; + margin-left: 12px; + } + .project-group-header { + font-size: 13px; + font-weight: 600; + padding: 12px 0 6px; + opacity: 0.7; + display: flex; + align-items: center; + gap: 6px; + } + .project-group-header .current-marker { + font-size: 10px; + font-weight: 500; + opacity: 0.6; + } + .project-badge { + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + font-weight: 500; + margin-right: 6px; + flex-shrink: 0; + background: rgba(130, 170, 196, 0.12); + color: var(--vscode-descriptionForeground, #8b8b8b); + } .provider-badge { font-size: 10px; padding: 1px 6px; @@ -440,6 +475,7 @@ class SessionExplorer extends LitElement { @state() private sessions: Session[] = []; @state() private emptyCount = 0; @state() private activeFilter: SourceFilter = "all"; + @state() private scopeFilter: ScopeFilter = "all-projects"; @state() private selectedSession: Session | null = null; @state() private selectedRequest: SessionRequest | null = null; @state() private customAgentNames: string[] = []; @@ -488,6 +524,19 @@ class SessionExplorer extends LitElement { vscode.postMessage({ type: "filter-change", provider: filter }); } + private onScopeChange(scope: ScopeFilter): void { + this.scopeFilter = scope; + vscode.postMessage({ type: "scope-change", scope }); + } + + private renderProjectBadge(session: Session) { + if (!session.projectName) return null; + const label = session.isCurrentWorkspace + ? `${session.projectName} (current)` + : session.projectName; + return html`${label}`; + } + private formatDate(ts: number): string { return new Date(ts).toLocaleString(); } @@ -654,6 +703,15 @@ class SessionExplorer extends LitElement { (a, b) => b.creationDate - a.creationDate, ); + // Check if we have project metadata (global discovery is active) + const hasProjects = sorted.some((s) => s.projectName); + + // Apply scope filter + const filtered = + hasProjects && this.scopeFilter === "current-project" + ? sorted.filter((s) => s.isCurrentWorkspace) + : sorted; + const filterOptions: { value: SourceFilter; label: string }[] = [ { value: "all", label: "All" }, { value: "copilot", label: "Copilot" }, @@ -661,62 +719,151 @@ class SessionExplorer extends LitElement { { value: "codex", label: "Codex" }, ]; + const scopeOptions: { value: ScopeFilter; label: string }[] = [ + { value: "all-projects", label: "All Projects" }, + { value: "current-project", label: "Current Project" }, + ]; + + // Group sessions by project for display + const groups = hasProjects ? this.groupByProject(filtered) : null; + return html` -
- ${filterOptions.map( - (opt) => html` - - `, - )} +
+
+ ${filterOptions.map( + (opt) => html` + + `, + )} +
+ ${hasProjects + ? html` +
+ ${scopeOptions.map( + (opt) => html` + + `, + )} +
+ ` + : null}

Session Explorer

- ${sorted.length === 0 + ${filtered.length === 0 ? html`
No sessions found. Sessions are auto-discovered from VS Code workspace storage.
` - : html` -
- ${sorted.map( - (session) => html` -
- - ${session.provider === "copilot" ? "Copilot" : session.provider === "claude" ? "Claude" : "Codex"} - - ${this.renderEnvBadge(session)} - ${this.renderRecoveredBadge(session)} - ${this.renderAgentBadge(session)} - ${this.renderSkillsBadge(session)} - - ${session.title ?? session.sessionId} - - - ${session.requests.length} requests - - - ${this.formatDate(session.creationDate)} - -
- `, - )} -
- ${this.emptyCount > 0 - ? html`
- ${this.emptyCount} empty session${this.emptyCount === 1 ? "" : "s"} hidden (0 requests) -
` + : groups + ? this.renderGroupedSessions(groups) + : html` +
+ ${filtered.map((session) => this.renderSessionItem(session))} +
+ `} + ${this.emptyCount > 0 + ? html`
+ ${this.emptyCount} empty session${this.emptyCount === 1 ? "" : "s"} hidden (0 requests) +
` + : null} + `; + } + + private groupByProject( + sessions: Session[], + ): { name: string; isCurrent: boolean; sessions: Session[] }[] { + const map = new Map(); + const ungrouped: Session[] = []; + + for (const s of sessions) { + if (s.projectName) { + let group = map.get(s.projectName); + if (!group) { + group = { isCurrent: s.isCurrentWorkspace === true, sessions: [] }; + map.set(s.projectName, group); + } + group.sessions.push(s); + } else { + ungrouped.push(s); + } + } + + // Sort: current workspace first, then alphabetical + const groups = Array.from(map.entries()) + .map(([name, g]) => ({ name, ...g })) + .sort((a, b) => { + if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + // Add ungrouped sessions as a catch-all group + if (ungrouped.length > 0) { + groups.push({ name: "Other", isCurrent: false, sessions: ungrouped }); + } + + return groups; + } + + private renderGroupedSessions( + groups: { name: string; isCurrent: boolean; sessions: Session[] }[], + ) { + return html` + ${groups.map( + (group) => html` +
+ ${group.name} + ${group.isCurrent + ? html`(current)` : null} - `} + ${group.sessions.length} session${group.sessions.length === 1 ? "" : "s"} +
+
+ ${group.sessions.map((session) => this.renderSessionItem(session))} +
+ `, + )} + `; + } + + private renderSessionItem(session: Session) { + return html` +
+ + ${session.provider === "copilot" + ? "Copilot" + : session.provider === "claude" + ? "Claude" + : "Codex"} + + ${this.renderProjectBadge(session)} ${this.renderEnvBadge(session)} + ${this.renderRecoveredBadge(session)} ${this.renderAgentBadge(session)} + ${this.renderSkillsBadge(session)} + + ${session.title ?? session.sessionId} + + ${session.requests.length} requests + + ${this.formatDate(session.creationDate)} + +
`; } diff --git a/webview/timeline.ts b/webview/timeline.ts index 1d157d9..f6b7504 100644 --- a/webview/timeline.ts +++ b/webview/timeline.ts @@ -30,7 +30,7 @@ const MINIMAP_HEIGHT = 24; export function formatTokens( usage: SessionRequestLike["usage"], ): string { - return `Prompt: ${usage.promptTokens.toLocaleString()} | Completion: ${usage.completionTokens.toLocaleString()}`; + return `Prompt: ${usage.promptTokens.toLocaleString("en-US")} | Completion: ${usage.completionTokens.toLocaleString("en-US")}`; } export function formatCacheTokens( @@ -40,8 +40,8 @@ export function formatCacheTokens( const read = usage.cacheReadTokens ?? 0; if (creation === 0 && read === 0) return null; const parts: string[] = []; - if (creation > 0) parts.push(`Cache create: ${creation.toLocaleString()}`); - if (read > 0) parts.push(`Cache read: ${read.toLocaleString()}`); + if (creation > 0) parts.push(`Cache create: ${creation.toLocaleString("en-US")}`); + if (read > 0) parts.push(`Cache read: ${read.toLocaleString("en-US")}`); return parts.join(" | "); }