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
24 changes: 15 additions & 9 deletions src/providers/cline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { stat } from 'fs/promises'
import { homedir } from 'os'
import { basename, join } from 'path'

import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePaths } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'

const EXTENSION_ID = 'saoudrizwan.claude-dev'

Expand Down Expand Up @@ -38,6 +38,14 @@ async function dedupeTaskSources(sources: SessionSource[]): Promise<SessionSourc

export function createClineProvider(overrideDirs?: string | string[]): Provider {
const configuredDirs = normalizeOverrideDirs(overrideDirs)
// 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. Shared by discovery and
// probeRoots so doctor can never report a root discovery does not read.
const taskRoots = (): string[] => configuredDirs ?? [
...clineTaskRoots(EXTENSION_ID),
getClineDataPath(),
]

return {
name: 'cline',
Expand All @@ -51,14 +59,12 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return taskRoots().map(path => ({ path, label: 'tasks' }))
},

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 ?? [
...getVSCodeGlobalStoragePaths(EXTENSION_ID),
getClineDataPath(),
]
const baseDirs = taskRoots()

return dedupeTaskSources(await discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', baseDirs))
},
Expand Down
6 changes: 5 additions & 1 deletion src/providers/grok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

// Grok Build (xAI's coding CLI) stores one session per directory at
// <grok-home>/sessions/<url-encoded-cwd>/<uuid>/, where grok-home is $GROK_HOME
Expand Down Expand Up @@ -257,6 +257,10 @@ export function createGrokProvider(sessionsDir?: string): Provider {
name: 'grok',
displayName: 'Grok Build',

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
},

modelDisplayName(model: string): string {
if (model.startsWith('grok-build')) return 'Grok Build'
return getShortModelName(model)
Expand Down
12 changes: 10 additions & 2 deletions src/providers/kilo-code.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { join } from 'path'
import { homedir } from 'os'

import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'

const EXTENSION_ID = 'kilocode.kilo-code'
const PROVIDER_NAME = 'kilo-code'
Expand Down Expand Up @@ -33,6 +33,14 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
// Both halves of discovery: the legacy task tree and the SQLite store.
return [
...clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' })),
{ path: sqliteConfig.dbDir, label: 'sqlite' },
]
},

async discoverSessions(): Promise<SessionSource[]> {
const [oldSessions, dbSessions] = await Promise.all([
discoverClineTasks(EXTENSION_ID, PROVIDER_NAME, 'KiloCode', overrideDir),
Expand Down
6 changes: 5 additions & 1 deletion src/providers/kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { homedir } from 'os'
import { extractBashCommands } from '../bash-utils.js'
import { readSessionLines } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'

type JsonObject = Record<string, unknown>

Expand Down Expand Up @@ -346,6 +346,10 @@ export function createKimiProvider(overrideDir?: string): Provider {
name: 'kimi',
displayName: 'Kimi',

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: join(shareDir, 'sessions'), label: 'sessions' }]
},

modelDisplayName(model: string): string {
return getShortModelName(model)
},
Expand Down
10 changes: 9 additions & 1 deletion src/providers/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { normalizeContentBlocks } from '../content-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'

const modelDisplayNames: Record<string, string> = {
'gpt-5.4': 'GPT-5.4',
Expand Down Expand Up @@ -272,6 +272,10 @@ export function createPiProvider(sessionsDir?: string): Provider {

return {
name: 'pi',

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
},
displayName: 'Pi',

modelDisplayName(model: string): string {
Expand Down Expand Up @@ -302,6 +306,10 @@ export function createOmpProvider(sessionsDir?: string): Provider {

return {
name: 'omp',

async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
},
displayName: 'OMP',

modelDisplayName(model: string): string {
Expand Down
8 changes: 6 additions & 2 deletions src/providers/roo-code.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'

const EXTENSION_ID = 'rooveterinaryinc.roo-cline'

Expand All @@ -16,6 +16,10 @@ export function createRooCodeProvider(overrideDir?: string | string[]): Provider
return rawTool
},

async probeRoots(): Promise<ProbeRoot[]> {
return clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' }))
},

async discoverSessions(): Promise<SessionSource[]> {
return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir)
},
Expand Down
13 changes: 10 additions & 3 deletions src/providers/vscode-cline-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,18 @@ 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
// The roots discoverClineTasks scans: an explicit override wins, otherwise
// every VS Code variant's globalStorage. Exported so a provider's probeRoots()
// can report exactly what discovery reads by calling the same function, rather
// than mirroring this logic and drifting from it.
export function clineTaskRoots(extensionId: string, overrideDir?: string | string[]): string[] {
return overrideDir
? (Array.isArray(overrideDir) ? overrideDir : [overrideDir])
: getVSCodeGlobalStoragePaths(extensionId)
return discoverClineTasksInBaseDirs(baseDirs, providerName, displayName)
}

export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> {
return discoverClineTasksInBaseDirs(clineTaskRoots(extensionId, overrideDir), providerName, displayName)
}

export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise<SessionSource[]> {
Expand Down
110 changes: 110 additions & 0 deletions tests/provider-probe-roots-tier2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { isAbsolute, join } from 'path'
import { homedir } from 'os'

import { createClineProvider, getClineDataPath } from '../src/providers/cline.js'
import { createRooCodeProvider } from '../src/providers/roo-code.js'
import { createKiloCodeProvider } from '../src/providers/kilo-code.js'
import { createGrokProvider } from '../src/providers/grok.js'
import { createPiProvider, createOmpProvider } from '../src/providers/pi.js'
import { createKimiProvider } from '../src/providers/kimi.js'
import {
clineTaskRoots,
discoverClineTasks,
getVSCodeGlobalStoragePaths,
} from '../src/providers/vscode-cline-parser.js'

// #899 Tier 2, batch 1. probeRoots() must report the roots discovery actually
// reads: a probe pointing somewhere discovery never looks is worse than none,
// because it looks authoritative. Assertions pin exact root sets rather than
// substrings, so a wrong-but-similar path cannot pass.
//
// This file is separate from the Tier 1 suite only because #903 introduces
// that one and is still open; fold the two together once it lands.

const CLINE_EXTENSION = 'saoudrizwan.claude-dev'
const ROO_EXTENSION = 'rooveterinaryinc.roo-cline'

describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
it('cline reports exactly the roots discovery scans', async () => {
// The provider whose silence motivated #874: four places to look, and until
// now no way to see which of them CodeBurn actually read.
const roots = await createClineProvider().probeRoots!()
expect(roots).toEqual([
...clineTaskRoots(CLINE_EXTENSION).map(path => ({ path, label: 'tasks' })),
{ path: getClineDataPath(), label: 'tasks' },
])
expect(roots).toHaveLength(4)
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
})

it('cline reports the configured dirs verbatim when overridden', async () => {
expect(await createClineProvider(['/tmp/cline-a', '/tmp/cline-b']).probeRoots!()).toEqual([
{ path: '/tmp/cline-a', label: 'tasks' },
{ path: '/tmp/cline-b', label: 'tasks' },
])
})

it('roo-code reports the override, or exactly the VS Code variant roots', async () => {
expect(await createRooCodeProvider('/tmp/roo-a').probeRoots!()).toEqual([
{ path: '/tmp/roo-a', label: 'tasks' },
])
expect(await createRooCodeProvider().probeRoots!()).toEqual(
getVSCodeGlobalStoragePaths(ROO_EXTENSION).map(path => ({ path, label: 'tasks' })),
)
})

// Regression: an earlier draft mirrored the resolution in a local helper that
// detected "no override" with `=== undefined`, while discoverClineTasks uses
// truthiness. An empty-string override made doctor report [""] while
// discovery scanned the three default roots. Both now call one resolver.
it('an empty-string override resolves the same for probeRoots and discovery', async () => {
const probed = (await createRooCodeProvider('').probeRoots!()).map(r => r.path)
expect(probed).toEqual(clineTaskRoots(ROO_EXTENSION, ''))
expect(probed).toEqual(getVSCodeGlobalStoragePaths(ROO_EXTENSION))
// discoverClineTasks resolves through the same function, so an empty
// override cannot send discovery somewhere probeRoots did not report.
expect(await discoverClineTasks(ROO_EXTENSION, 'roo-code', 'Roo Code', '')).toEqual([])
})

it('kilo-code reports both halves of its discovery: tasks and the sqlite store', async () => {
const roots = await createKiloCodeProvider('/tmp/kilo-a').probeRoots!()
expect(roots[0]).toEqual({ path: '/tmp/kilo-a', label: 'tasks' })
const sqlite = roots.filter(r => r.label === 'sqlite')
expect(sqlite).toHaveLength(1)
// The same dbDir discoverSqliteSessions reads, not a lookalike.
expect(sqlite[0]!.path).toBe(
join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'kilo'),
)
})

it('grok reports exactly its resolved sessions dir', async () => {
expect(await createGrokProvider('/tmp/grok-a').probeRoots!()).toEqual([
{ path: '/tmp/grok-a', label: 'sessions' },
])
expect(await createGrokProvider().probeRoots!()).toEqual([
{ path: join(homedir(), '.grok', 'sessions'), label: 'sessions' },
])
})

it('pi and omp each report their own sessions dir', async () => {
expect(await createPiProvider('/tmp/pi-a').probeRoots!()).toEqual([
{ path: '/tmp/pi-a', label: 'sessions' },
])
expect(await createOmpProvider('/tmp/omp-a').probeRoots!()).toEqual([
{ path: '/tmp/omp-a', label: 'sessions' },
])
// Same module, two providers: the roots must not collide.
const [piRoot] = await createPiProvider().probeRoots!()
const [ompRoot] = await createOmpProvider().probeRoots!()
expect(piRoot!.path).not.toBe(ompRoot!.path)
})

it('kimi reports the sessions dir under its share root, not the share root itself', async () => {
// Discovery walks <shareDir>/sessions; reporting shareDir would point doctor
// at a directory that exists even when no sessions do.
expect(await createKimiProvider('/tmp/kimi-a').probeRoots!()).toEqual([
{ path: join('/tmp/kimi-a', 'sessions'), label: 'sessions' },
])
})
})
Loading