Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading docs: The description says "Claude, Copilot, and Codex" but the Codex provider doesn't read this setting — it always scans all sessions. Either update Codex to respect the setting, or narrow the description.

}
}
},
Expand Down
8 changes: 5 additions & 3 deletions src/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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) => {
Expand Down
17 changes: 16 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (refreshing) {
refreshQueued = true;
return;
}
refreshing = true;

const log = getLogger();
const start = Date.now();
log.debug("Refresh started");
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -193,7 +208,7 @@ export function activate(context: vscode.ExtensionContext): void {
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => refresh(sessionCtx, treeProvider), 500);
refreshTimer = setTimeout(() => refresh(sessionCtx, treeProvider), 2000);
}

const agentWatcher = vscode.workspace.createFileSystemWatcher(
Expand Down
4 changes: 4 additions & 0 deletions src/models/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
44 changes: 43 additions & 1 deletion src/parsers/claudeLocator.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading