From b67311e9905c6f9b79ac8199d9cd97bff0f82f4f Mon Sep 17 00:00:00 2001 From: "Rick Culpepper (claude)" Date: Wed, 5 Aug 2026 06:16:42 -0500 Subject: [PATCH] feat(doctor): probeRoots for six fixed-location providers (#899 Tier 2, batch 1) Tier 2 of #899: cline, roo-code, kilo-code, grok, pi/omp and kimi report the roots their discovery actually reads, so `codeburn doctor` can tell "tool not installed" from "configured root is empty" for them. Batch 1 is deliberately the Cline family and its closest cousins. `cline` is the provider whose silence motivated #874 and it still had no probeRoots: the CLI half got one, the extension half never did. Its four roots - three VS Code variant globalStorage paths plus `~/.cline/data` - make the Tier 2 case concretely, because today there is no way to see which of the four was found. Before: Cline 0 NOTHING FOUND (tool likely not installed or no history yet) KiloCode 0 NOTHING FOUND (tool likely not installed or no history yet) After, on a machine with the Cline CLI installed and the extension not: Cline 0 NOTHING FOUND (~/.cline/data exists but holds no sessions) KiloCode 0 NOTHING FOUND (...kilocode.kilo-code does not exist; tool likely not installed) ## One shared resolver instead of a mirror probeRoots() is only useful if it reports what discovery truly reads, so the resolution is shared rather than duplicated. `vscode-cline-parser.ts` gains `clineTaskRoots(extensionId, overrideDir)`, `discoverClineTasks` is rewritten to call it, and cline / roo-code / kilo-code use the same function for their probes. That is the entire change to the shared parser: a pure extraction with no parsing touched, so Roo Code, KiloCode and IBM Bob are unaffected - their suites and the parser's own are green. An earlier draft mirrored the logic in a local helper instead, and the mirror had already drifted: it detected "no override" with `=== undefined` while `discoverClineTasks` uses truthiness, so an empty-string override made doctor report `[""]` while discovery scanned the three default roots - a probe pointing where discovery never looked, which is the exact failure this method exists to prevent. One resolver makes that unrepresentable; the regression test pins it anyway. ## Per-provider notes - kilo-code reports both halves of its split discovery: the legacy task tree and the SQLite store, labelled separately. - pi and omp are two providers in one module; each reports its own sessions dir, with a test that they cannot collide. - kimi reports `/sessions`, not shareDir. Discovery walks the former; reporting the latter would point doctor at a directory that exists even when no sessions do - the false reassurance this campaign removes. - Candidate roots are reported before any existence filter, per #903, since doctor's job is to show where it looked. Assertions pin exact root sets rather than substrings, so a wrong-but-similar path cannot pass, and each guard is mutation-checked: reverting the resolver to `=== undefined`, making cline's probe ignore its override, and making kimi report shareDir each fail the suite. Tests live in their own file because #903 introduces the Tier 1 suite and is still open; happy to fold the two together once it lands. --- src/providers/cline.ts | 24 +++-- src/providers/grok.ts | 6 +- src/providers/kilo-code.ts | 12 ++- src/providers/kimi.ts | 6 +- src/providers/pi.ts | 10 ++- src/providers/roo-code.ts | 8 +- src/providers/vscode-cline-parser.ts | 13 ++- tests/provider-probe-roots-tier2.test.ts | 110 +++++++++++++++++++++++ 8 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 tests/provider-probe-roots-tier2.test.ts diff --git a/src/providers/cline.ts b/src/providers/cline.ts index 51a4c9a5..f7d2d9cc 100644 --- a/src/providers/cline.ts +++ b/src/providers/cline.ts @@ -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' @@ -38,6 +38,14 @@ async function dedupeTaskSources(sources: SessionSource[]): Promise configuredDirs ?? [ + ...clineTaskRoots(EXTENSION_ID), + getClineDataPath(), + ] return { name: 'cline', @@ -51,14 +59,12 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider return rawTool }, + async probeRoots(): Promise { + return taskRoots().map(path => ({ path, label: 'tasks' })) + }, + async discoverSessions(): Promise { - // 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)) }, diff --git a/src/providers/grok.ts b/src/providers/grok.ts index 7e347f91..1c292441 100644 --- a/src/providers/grok.ts +++ b/src/providers/grok.ts @@ -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 // /sessions///, where grok-home is $GROK_HOME @@ -257,6 +257,10 @@ export function createGrokProvider(sessionsDir?: string): Provider { name: 'grok', displayName: 'Grok Build', + async probeRoots(): Promise { + return [{ path: dir, label: 'sessions' }] + }, + modelDisplayName(model: string): string { if (model.startsWith('grok-build')) return 'Grok Build' return getShortModelName(model) diff --git a/src/providers/kilo-code.ts b/src/providers/kilo-code.ts index 249fb5d2..70d89bbf 100644 --- a/src/providers/kilo-code.ts +++ b/src/providers/kilo-code.ts @@ -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' @@ -33,6 +33,14 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide return rawTool }, + async probeRoots(): Promise { + // 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 { const [oldSessions, dbSessions] = await Promise.all([ discoverClineTasks(EXTENSION_ID, PROVIDER_NAME, 'KiloCode', overrideDir), diff --git a/src/providers/kimi.ts b/src/providers/kimi.ts index 75242cc8..ceb98519 100644 --- a/src/providers/kimi.ts +++ b/src/providers/kimi.ts @@ -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 @@ -346,6 +346,10 @@ export function createKimiProvider(overrideDir?: string): Provider { name: 'kimi', displayName: 'Kimi', + async probeRoots(): Promise { + return [{ path: join(shareDir, 'sessions'), label: 'sessions' }] + }, + modelDisplayName(model: string): string { return getShortModelName(model) }, diff --git a/src/providers/pi.ts b/src/providers/pi.ts index 31abc855..c8b42f12 100644 --- a/src/providers/pi.ts +++ b/src/providers/pi.ts @@ -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 = { 'gpt-5.4': 'GPT-5.4', @@ -272,6 +272,10 @@ export function createPiProvider(sessionsDir?: string): Provider { return { name: 'pi', + + async probeRoots(): Promise { + return [{ path: dir, label: 'sessions' }] + }, displayName: 'Pi', modelDisplayName(model: string): string { @@ -302,6 +306,10 @@ export function createOmpProvider(sessionsDir?: string): Provider { return { name: 'omp', + + async probeRoots(): Promise { + return [{ path: dir, label: 'sessions' }] + }, displayName: 'OMP', modelDisplayName(model: string): string { diff --git a/src/providers/roo-code.ts b/src/providers/roo-code.ts index 4059d96a..59194104 100644 --- a/src/providers/roo-code.ts +++ b/src/providers/roo-code.ts @@ -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' @@ -16,6 +16,10 @@ export function createRooCodeProvider(overrideDir?: string | string[]): Provider return rawTool }, + async probeRoots(): Promise { + return clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' })) + }, + async discoverSessions(): Promise { return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir) }, diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts index 2535a22c..99739b2f 100644 --- a/src/providers/vscode-cline-parser.ts +++ b/src/providers/vscode-cline-parser.ts @@ -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 { - 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 { + return discoverClineTasksInBaseDirs(clineTaskRoots(extensionId, overrideDir), providerName, displayName) } export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise { diff --git a/tests/provider-probe-roots-tier2.test.ts b/tests/provider-probe-roots-tier2.test.ts new file mode 100644 index 00000000..0c34f079 --- /dev/null +++ b/tests/provider-probe-roots-tier2.test.ts @@ -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 /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' }, + ]) + }) +})