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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
- Sessions that were silently invisible now appear: Pi and Oh My Pi transcripts with an OMP title slot, Cline sessions under Code - Insiders or VSCodium roots, and OpenCode/kilo-code usage that silently read as zero now reports. (#845)

## 0.9.19 - 2026-07-20

Expand Down
27 changes: 24 additions & 3 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,28 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 17: pi/omp, cline and opencode/kilo-code session discovery
// was restored (#845, this PR). v15/v16 rollups missed OMP sessions
// written after a `type: "title"` slot line, Cline sessions under the
// Insiders / VSCodium / home-data roots, and opencode/kilo-code
// interrupted or user-only sessions whose session-level fallback queried
// a `model_id` column neither schema has. Those files were skipped before
// they were ever parsed, so nothing downstream can notice on its own: the
// daily cache serves every day before today, retention is ten years, and
// an upgrading user with a warm complete cache would keep the pre-fix
// zeroes forever while today's numbers silently disagreed with them.
// Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation.
//
// v16 is skipped on purpose: main already spent it on the codex
// structural-discovery fix (eece4cf). The cache filename and the accepted
// version window ([MIN_SUPPORTED_VERSION, DAILY_CACHE_VERSION]) are
// shared across trees, so claiming 16 here would load a main-built v16
// cache — which carries only the codex fix, none of this PR's — as
// current and complete, and the invalidation would never fire. 17 is the
// first version that contains both the codex fix and this PR's discovery
// fixes; the next bump must check what main has already claimed.
//
// v15: per-project daily rollups. Days and provider slices now carry
// a `projects` breakdown (cost/calls/savings/sessions per project) so project
// history outlives the session files, like models and categories already do.
// This bump is the first to ride the v14 carry-forward: the old cache is
Expand Down Expand Up @@ -57,8 +78,8 @@ import type { DateRange, ProjectSummary } from './types.js'
// that older binaries skipped. v8 added local-model savings to the daily
// rollup; the `savingsConfigHash` field is invalidated separately when the
// user changes their `localModelSavings` mapping.
export const DAILY_CACHE_VERSION = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 17
const MIN_SUPPORTED_VERSION = 17
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down
15 changes: 7 additions & 8 deletions packages/cli/src/providers/cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline'
import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline'

import { createBridgedProvider } from './bridge.js'
import { discoverClineTasks, getVSCodeGlobalStoragePath, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js'
import { discoverClineTasks, getVSCodeGlobalStoragePaths, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js'
import type { Provider, SessionSource } from './types.js'

const EXTENSION_ID = 'saoudrizwan.claude-dev'
Expand All @@ -17,7 +17,7 @@ export function getClineDataPath(): string {

function normalizeOverrideDirs(overrideDirs?: string | string[]): string[] | undefined {
if (overrideDirs === undefined) return undefined
// Cline has two default roots, so tests and future callers can override one or both.
// Cline has several default roots, so tests and future callers can override one or all.
return Array.isArray(overrideDirs) ? overrideDirs : [overrideDirs]
}

Expand Down Expand Up @@ -56,16 +56,15 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider
},

async discoverSessions(): Promise<SessionSource[]> {
// Cline may be installed in any VS Code variant (stable, Insiders,
// VSCodium), so every globalStorage root is scanned - same as the Roo Code
// and KiloCode siblings - plus Cline's own home-data root.
const baseDirs = configuredDirs ?? [
getVSCodeGlobalStoragePath(EXTENSION_ID),
...getVSCodeGlobalStoragePaths(EXTENSION_ID),
getClineDataPath(),
]

const sources = await Promise.all(
baseDirs.map(dir => discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', dir)),
)

return dedupeTaskSources(sources.flat())
return dedupeTaskSources(await discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', baseDirs))
},

readRecords: readClineRecords,
Expand Down
49 changes: 35 additions & 14 deletions packages/cli/src/providers/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { homedir } from 'os'

import { decodePi } from '@codeburn/core/providers/pi'
import type { PiDecodedCall } from '@codeburn/core/providers/pi'
import { readSessionFile } from '../fs-utils.js'
import { readSessionFile, readSessionLines } from '../fs-utils.js'
import { extractBashCommands } from '../bash-utils.js'
import { createBridgedProvider } from './bridge.js'
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'
Expand All @@ -13,7 +13,10 @@ type PiEntry = {
type: string
id?: string
timestamp?: string
cwd?: string
/// JSON can carry anything here; the reader validates before use (a real
/// transcript always writes a string, but a malformed record must not
/// crash discovery with a `basename` type error).
cwd?: unknown
message?: unknown
}

Expand All @@ -25,16 +28,31 @@ function getOmpSessionsDir(override?: string): string {
return override ?? join(homedir(), '.omp', 'agent', 'sessions')
}

async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
const line = content.split('\n')[0]
if (!line?.trim()) return null
try {
return JSON.parse(line) as PiEntry
} catch {
return null
// OMP can write a fixed-width title metadata line (`type: "title"`) before the
// `type: "session"` header (issue #845), and either provider may pad the
// header with blank lines. Scan a bounded number of leading lines rather than
// just the first one, so discovery stops after twenty parse attempts and never
// walks a message-only or pathological transcript line by line to EOF. The
// bound caps how many LINES are inspected, not how many bytes: readSessionLines
// buffers one physical line whole, so a single oversized line is bounded only
// by the streaming reader's cap, not by this constant.
const MAX_HEADER_LINES_SCANNED = 20

async function readSessionEntry(filePath: string): Promise<PiEntry | null> {
let linesScanned = 0
for await (const line of readSessionLines(filePath)) {
if (linesScanned >= MAX_HEADER_LINES_SCANNED) break
linesScanned++
const trimmed = line.trim()
if (!trimmed) continue
try {
const entry = JSON.parse(trimmed) as PiEntry
if (entry.type === 'session') return entry
} catch {
continue
}
}
return null
}

async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise<SessionSource[]> {
Expand Down Expand Up @@ -65,10 +83,13 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string):
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile()) continue

const first = await readFirstEntry(filePath)
if (!first || first.type !== 'session') continue
const entry = await readSessionEntry(filePath)
if (!entry) continue

const cwd = first.cwd ?? dirName
// A malformed record can carry a non-string cwd (e.g. a number); fall
// back to the project directory the way a missing cwd does, rather than
// crashing basename with a type error.
const cwd = typeof entry.cwd === 'string' && entry.cwd.length > 0 ? entry.cwd : dirName
sources.push({ path: filePath, project: basename(cwd), provider: providerName })
}
}
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/providers/sqlite-session-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,21 @@ type SessionTokenRow = {
tokens_reasoning?: number
tokens_cache_read?: number
tokens_cache_write?: number
model_id?: string
model?: Uint8Array | string
}

function parseSessionModel(value: Uint8Array | string | undefined): string | undefined {
try {
const parsed: unknown = JSON.parse(blobToText(value))
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined

const model = parsed as Record<string, unknown>
const id = typeof model['id'] === 'string' ? model['id'].trim() : ''
const providerID = typeof model['providerID'] === 'string' ? model['providerID'].trim() : ''
return id && providerID ? `${providerID}/${id}` : undefined
} catch {
return undefined
}
}

export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): {
Expand All @@ -42,7 +56,9 @@ export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): {
} | null {
try {
const rows = db.query<SessionTokenRow>(
`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, model_id FROM session WHERE id = ?`,
`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
CAST(model AS BLOB) AS model
FROM session WHERE id = ?`,
[sessionId],
)
if (rows.length === 0) return null
Expand All @@ -54,7 +70,7 @@ export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): {
reasoning: r.tokens_reasoning ?? 0,
cacheRead: r.tokens_cache_read ?? 0,
cacheWrite: r.tokens_cache_write ?? 0,
model: r.model_id ?? undefined,
model: parseSessionModel(r.model),
}
} catch {
return null
Expand Down
4 changes: 0 additions & 4 deletions packages/cli/src/providers/vscode-cline-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homed
]
}

export function getVSCodeGlobalStoragePath(extensionId: string): string {
return getVSCodeGlobalStoragePaths(extensionId)[0]!
}

export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> {
const baseDirs = overrideDir
? (Array.isArray(overrideDir) ? overrideDir : [overrideDir])
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',
kiro: 'ide-parsing-v1-est-cost',
opencode: 'session-model-v1',
quickdesk: 'emf-sqlite-v2-est-cost',
kimicode: 'wire-usage-v1-est-cost',
'kilo-code': 'worktree-project-grouping-v1',
'kilo-code': 'worktree-project-grouping-v1-session-model-v1',
'roo-code': 'worktree-project-grouping-v1',
warp: 'worktree-project-grouping-v1-est-cost',
antigravity: 'worktree-project-grouping-v5',
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/tests/daily-cache-carry-forward.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
Expand Down Expand Up @@ -304,6 +304,45 @@ describe('never-lose invariant: invalidations with vanished sources', () => {
expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true })
})

it('a version bump forces a re-derive that recovers usage the old cache never had', async () => {
// The pre-fix binary shipped daily-cache v16 (main's codex discovery
// fix spent 16; this PR's fixes land at 17). THIS LITERAL IS THE POINT:
// it must stay pinned to the version the pre-fix binary wrote, so a warm
// cache from that binary sits at daily-cache.v16.json, complete: true,
// with no opencode usage (the session-level fallback and pi/omp discovery
// fixes landed after). Only the MIN_SUPPORTED_VERSION bump decides
// whether that file loads as the trusted CURRENT cache — freezing the
// pre-fix zeroes forever — or as an old-version file that forces the
// one-time re-derive. When the next bump lands, move this literal to the
// version the current binary shipped.
const PRE_FIX_CACHE_VERSION = 16
const preFixCache: DailyCache = {
version: PRE_FIX_CACHE_VERSION,
savingsConfigHash: 'cfg-A',
tzKey: currentTzKey(),
lastComputedDate: daysAgoStr(1),
days: [seededDay()],
complete: true,
}
await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${PRE_FIX_CACHE_VERSION}.json`), JSON.stringify(preFixCache), 'utf-8')

const aggregate = vi.fn(() => [day(daysAgoStr(30), { opencode: slice(12.5, 7) })])
const out = await ensureCacheHydrated(noSessions, aggregate, 'cfg-A')

// The bump forced a full re-derivation: the fresh parse was consulted.
expect(aggregate).toHaveBeenCalled()
expect(out.version).toBe(DAILY_CACHE_VERSION)
expect(out.complete).toBe(true)
// The recovered usage is picked up by the re-derive…
expect(out.days[0]!.providers['opencode']!.cost).toBe(12.5)
expect(out.days[0]!.providers['opencode']!.calls).toBe(7)
// …and providers the parse could not re-derive keep their old
// accounting, carried forward (never-lose invariant intact).
expect(out.days[0]!.providers['claude']!.cost).toBe(230.06)
expect(out.days[0]!.providers['codex']!.cost).toBe(79.29)
expect(out.days[0]!.carried).toBe(true)
})

it('a same-version file found under an old name is trusted as-is (no spurious rebuild)', async () => {
const d = await seed()
await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.json'))
Expand Down
77 changes: 76 additions & 1 deletion packages/cli/tests/providers/cline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'

import { cline, createClineProvider } from '../../src/providers/cline.js'
import { cline, createClineProvider, getClineDataPath } from '../../src/providers/cline.js'
import { getVSCodeGlobalStoragePaths } from '../../src/providers/vscode-cline-parser.js'
import { priceProviderCall } from '../../src/pricing-pass.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'

const EXTENSION_ID = 'saoudrizwan.claude-dev'

let tmpDir: string

async function writeTask(baseDir: string, taskId: string, opts?: {
Expand Down Expand Up @@ -94,6 +97,78 @@ describe('cline provider - discovery', () => {
})
})

describe('cline provider - default roots', () => {
let previousHome: string | undefined
let previousUserProfile: string | undefined

beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
previousHome = process.env['HOME']
previousUserProfile = process.env['USERPROFILE']
// os.homedir() reads HOME on POSIX and USERPROFILE on Windows - set both so
// the default roots resolve inside the sandbox on every platform.
process.env['HOME'] = tmpDir
process.env['USERPROFILE'] = tmpDir
})

afterEach(async () => {
if (previousHome === undefined) delete process.env['HOME']
else process.env['HOME'] = previousHome
if (previousUserProfile === undefined) delete process.env['USERPROFILE']
else process.env['USERPROFILE'] = previousUserProfile
await rm(tmpDir, { recursive: true, force: true })
})

it('discovers tasks from every VS Code variant (stable, Insiders, VSCodium)', async () => {
const roots = getVSCodeGlobalStoragePaths(EXTENSION_ID)
expect(roots).toHaveLength(3)
const [stableRoot, insidersRoot, codiumRoot] = roots
await writeTask(stableRoot!, 'task-stable')
await writeTask(insidersRoot!, 'task-insiders')
await writeTask(codiumRoot!, 'task-codium')

const sessions = await createClineProvider().discoverSessions()

expect(sessions).toHaveLength(3)
expect(sessions.every(s => s.provider === 'cline')).toBe(true)
expect(sessions.map(s => s.path).sort()).toEqual([
join(stableRoot!, 'tasks', 'task-stable'),
join(insidersRoot!, 'tasks', 'task-insiders'),
join(codiumRoot!, 'tasks', 'task-codium'),
].sort())
})

it('still scans the ~/.cline/data root alongside the VS Code variants', async () => {
const stableRoot = getVSCodeGlobalStoragePaths(EXTENSION_ID)[0]!
const clineDataDir = getClineDataPath()
expect(clineDataDir).toBe(join(tmpDir, '.cline', 'data'))
await writeTask(stableRoot, 'task-vscode')
await writeTask(clineDataDir, 'task-home')

const sessions = await createClineProvider().discoverSessions()

expect(sessions.map(s => s.path).sort()).toEqual([
join(stableRoot, 'tasks', 'task-vscode'),
join(clineDataDir, 'tasks', 'task-home'),
].sort())
})

it('does not double-count a task id shared by several VS Code variants', async () => {
const [stableRoot, insidersRoot, codiumRoot] = getVSCodeGlobalStoragePaths(EXTENSION_ID)
const stableTask = await writeTask(stableRoot!, 'task-same')
const insidersTask = await writeTask(insidersRoot!, 'task-same')
const codiumTask = await writeTask(codiumRoot!, 'task-same')
await utimes(join(stableTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'))
await utimes(join(codiumTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z'))
await utimes(join(insidersTask, 'ui_messages.json'), new Date('2026-03-01T00:00:00Z'), new Date('2026-03-01T00:00:00Z'))

const sessions = await createClineProvider().discoverSessions()

expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toBe(insidersTask)
})
})

describe('cline provider - parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
Expand Down
Loading
Loading