diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b74542..6ccc4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased ### Added (CLI) +- **VSCodium storage discovery for VS Code-family providers.** GitHub + Copilot, Roo Code, and KiloCode now scan VSCodium storage roots in addition + to VS Code and VS Code Insiders, so usage created from VSCodium is included + without a custom override path. - **Multiple subscription plans can be tracked at the same time.** `codeburn plan set` now stores plans in a provider-keyed `plans` map, so setting a Codex custom plan no longer overwrites an existing Claude plan. diff --git a/README.md b/README.md index fc847c0..b005b29 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,11 @@ The `--provider` flag filters any command to a single provider: `codeburn report **Kiro** stores conversations as `.chat` JSON files. Token counts are estimated from content length. The underlying model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. -**GitHub Copilot** reads from both `~/.copilot/session-state/` (legacy CLI) and VS Code's `workspaceStorage/*/GitHub.copilot-chat/transcripts/`. The VS Code format has no explicit token counts; tokens are estimated from content length and the model is inferred from tool call ID prefixes. +**GitHub Copilot** reads from both `~/.copilot/session-state/` (legacy CLI) and VS Code/VSCodium `workspaceStorage/*/GitHub.copilot-chat/transcripts/`. The editor transcript format has no explicit token counts; tokens are estimated from content length and the model is inferred from tool call ID prefixes. **OpenClaw** reads JSONL agent logs from `~/.openclaw/agents/` and also checks legacy paths (`.clawdbot`, `.moltbot`, `.moldbot`). -**Roo Code and KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory and extracts token usage from `api_req_started` entries. +**Roo Code and KiloCode** are Cline-family VS Code/VSCodium extensions. CodeBurn reads `ui_messages.json` from each task directory and extracts token usage from `api_req_started` entries. **Claude with multiple config directories.** If you run Claude Code under more than one account or profile (e.g. `~/.claude-work` and `~/.claude-personal`), point `CLAUDE_CONFIG_DIRS` at all of them at once: `CLAUDE_CONFIG_DIRS=~/.claude-work:~/.claude-personal codeburn`. Sessions across every directory are merged into one row per project so the totals reflect all your Claude usage in one place. Use `:` on POSIX, `;` on Windows. Missing or unreadable directories in the list are skipped. @@ -389,7 +389,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta **OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields. -**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`. +**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`; Roo Code and KiloCode scan VS Code, VS Code Insiders, and VSCodium `globalStorage` roots. **IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks//` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders. diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index e7c35e3..f08e29a 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -1,6 +1,6 @@ import { existsSync } from 'fs' import { readdir, readFile, stat } from 'fs/promises' -import { basename, dirname, join } from 'path' +import { basename, dirname, join, posix, win32 } from 'path' import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' @@ -106,7 +106,8 @@ function parseLegacyEvents(content: string, sessionId: string, seenKeys: Set + pendingUserMessage = userEvent.data.content ?? '' continue } @@ -330,25 +331,30 @@ function getCopilotSessionStateDir(override?: string): string { return override ?? join(homedir(), '.copilot', 'session-state') } -function getVSCodeWorkspaceStorageDirs(): string[] { - if (process.platform === 'darwin') { +export function getVSCodeWorkspaceStorageDirs(homeDir = homedir(), platform = process.platform): string[] { + const pathJoin = platform === 'win32' ? win32.join : posix.join + + if (platform === 'darwin') { return [ - join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'), - join(homedir(), 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'), + pathJoin(homeDir, 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'), + pathJoin(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'), + pathJoin(homeDir, 'Library', 'Application Support', 'VSCodium', 'User', 'workspaceStorage'), ] } - if (process.platform === 'win32') { + if (platform === 'win32') { return [ - join(homedir(), 'AppData', 'Roaming', 'Code', 'User', 'workspaceStorage'), - join(homedir(), 'AppData', 'Roaming', 'Code - Insiders', 'User', 'workspaceStorage'), + pathJoin(homeDir, 'AppData', 'Roaming', 'Code', 'User', 'workspaceStorage'), + pathJoin(homeDir, 'AppData', 'Roaming', 'Code - Insiders', 'User', 'workspaceStorage'), + pathJoin(homeDir, 'AppData', 'Roaming', 'VSCodium', 'User', 'workspaceStorage'), ] } return [ - join(homedir(), '.config', 'Code', 'User', 'workspaceStorage'), - join(homedir(), '.config', 'Code - Insiders', 'User', 'workspaceStorage'), - join(homedir(), '.vscode-server', 'data', 'User', 'workspaceStorage'), + pathJoin(homeDir, '.config', 'Code', 'User', 'workspaceStorage'), + pathJoin(homeDir, '.config', 'Code - Insiders', 'User', 'workspaceStorage'), + pathJoin(homeDir, '.config', 'VSCodium', 'User', 'workspaceStorage'), + pathJoin(homeDir, '.vscode-server', 'data', 'User', 'workspaceStorage'), ] } diff --git a/src/providers/kilo-code.ts b/src/providers/kilo-code.ts index 89c9a85..4630468 100644 --- a/src/providers/kilo-code.ts +++ b/src/providers/kilo-code.ts @@ -3,7 +3,7 @@ import type { Provider, SessionSource, SessionParser } from './types.js' const EXTENSION_ID = 'kilocode.kilo-code' -export function createKiloCodeProvider(overrideDir?: string): Provider { +export function createKiloCodeProvider(overrideDir?: string | string[]): Provider { return { name: 'kilo-code', displayName: 'KiloCode', diff --git a/src/providers/roo-code.ts b/src/providers/roo-code.ts index 5ea6ccc..4059d96 100644 --- a/src/providers/roo-code.ts +++ b/src/providers/roo-code.ts @@ -3,7 +3,7 @@ import type { Provider, SessionSource, SessionParser } from './types.js' const EXTENSION_ID = 'rooveterinaryinc.roo-cline' -export function createRooCodeProvider(overrideDir?: string): Provider { +export function createRooCodeProvider(overrideDir?: string | string[]): Provider { return { name: 'roo-code', displayName: 'Roo Code', diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts index ffad939..2535a22 100644 --- a/src/providers/vscode-cline-parser.ts +++ b/src/providers/vscode-cline-parser.ts @@ -1,5 +1,5 @@ import { readdir, readFile, stat } from 'fs/promises' -import { basename, join } from 'path' +import { basename, join, posix, win32 } from 'path' import { homedir } from 'os' import { calculateCost } from '../models.js' @@ -12,19 +12,41 @@ type UiMessage = { ts?: number } -export function getVSCodeGlobalStoragePath(extensionId: string): string { - if (process.platform === 'darwin') { - return join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', extensionId) +export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homedir(), platform = process.platform): string[] { + const pathJoin = platform === 'win32' ? win32.join : posix.join + + if (platform === 'darwin') { + return [ + pathJoin(homeDir, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'Library', 'Application Support', 'Code - Insiders', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage', extensionId), + ] } - if (process.platform === 'win32') { - return join(homedir(), 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', extensionId) + + if (platform === 'win32') { + return [ + pathJoin(homeDir, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'AppData', 'Roaming', 'Code - Insiders', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage', extensionId), + ] } - return join(homedir(), '.config', 'Code', 'User', 'globalStorage', extensionId) + + return [ + pathJoin(homeDir, '.config', 'Code', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, '.config', 'Code - Insiders', 'User', 'globalStorage', extensionId), + pathJoin(homeDir, '.config', 'VSCodium', 'User', 'globalStorage', extensionId), + ] +} + +export function getVSCodeGlobalStoragePath(extensionId: string): string { + return getVSCodeGlobalStoragePaths(extensionId)[0]! } -export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise { - const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId) - return discoverClineTasksInBaseDirs([baseDir], providerName, displayName) +export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise { + const baseDirs = overrideDir + ? (Array.isArray(overrideDir) ? overrideDir : [overrideDir]) + : getVSCodeGlobalStoragePaths(extensionId) + return discoverClineTasksInBaseDirs(baseDirs, providerName, displayName) } export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise { diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index 16cb6fd..2e8ae46 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' -import { join } from 'path' +import { join, posix, win32 } from 'path' import { tmpdir } from 'os' -import { copilot, createCopilotProvider } from '../../src/providers/copilot.js' +import { copilot, createCopilotProvider, getVSCodeWorkspaceStorageDirs } from '../../src/providers/copilot.js' import type { ParsedProviderCall } from '../../src/providers/types.js' let tmpDir: string @@ -365,6 +365,18 @@ describe('copilot provider - discoverSessions', () => { expect(sessions[0]!.project).toBe('myapp') expect(sessions[0]!.path).toContain('session-1.jsonl') }) + + it('includes VSCodium workspaceStorage paths on all supported platforms', () => { + expect(getVSCodeWorkspaceStorageDirs('/Users/test', 'darwin')).toContain( + posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'workspaceStorage'), + ) + expect(getVSCodeWorkspaceStorageDirs('C:\\Users\\test', 'win32')).toContain( + win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'workspaceStorage'), + ) + expect(getVSCodeWorkspaceStorageDirs('/home/test', 'linux')).toContain( + posix.join('/home/test', '.config', 'VSCodium', 'User', 'workspaceStorage'), + ) + }) }) describe('copilot provider - metadata', () => { diff --git a/tests/providers/vscode-cline-parser.test.ts b/tests/providers/vscode-cline-parser.test.ts new file mode 100644 index 0000000..b250b0c --- /dev/null +++ b/tests/providers/vscode-cline-parser.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join, posix, win32 } from 'path' +import { tmpdir } from 'os' + +import { discoverClineTasks, getVSCodeGlobalStoragePaths } from '../../src/providers/vscode-cline-parser.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'vscode-cline-parser-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeTask(baseDir: string, taskId: string): Promise { + const taskDir = join(baseDir, 'tasks', taskId) + await mkdir(taskDir, { recursive: true }) + await writeFile(join(taskDir, 'ui_messages.json'), '[]') +} + +describe('VS Code Cline-family storage discovery', () => { + it('includes VSCodium globalStorage paths on all supported platforms', () => { + const extensionId = 'example.extension' + + expect(getVSCodeGlobalStoragePaths(extensionId, '/Users/test', 'darwin')).toContain( + posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage', extensionId), + ) + expect(getVSCodeGlobalStoragePaths(extensionId, 'C:\\Users\\test', 'win32')).toContain( + win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage', extensionId), + ) + expect(getVSCodeGlobalStoragePaths(extensionId, '/home/test', 'linux')).toContain( + posix.join('/home/test', '.config', 'VSCodium', 'User', 'globalStorage', extensionId), + ) + }) + + it('discovers tasks across multiple VS Code-compatible storage roots', async () => { + const codeRoot = join(tmpDir, 'Code', 'User', 'globalStorage', 'example.extension') + const codiumRoot = join(tmpDir, 'VSCodium', 'User', 'globalStorage', 'example.extension') + await writeTask(codeRoot, 'task-code') + await writeTask(codiumRoot, 'task-codium') + + const sessions = await discoverClineTasks( + 'example.extension', + 'example-provider', + 'Example Provider', + [codeRoot, codiumRoot], + ) + + expect(sessions).toHaveLength(2) + expect(sessions.map(s => s.path).sort()).toEqual([ + join(codeRoot, 'tasks', 'task-code'), + join(codiumRoot, 'tasks', 'task-codium'), + ].sort()) + }) +})