Skip to content

Commit 55f61b5

Browse files
rajbosCopilot
andcommitted
refactor: Phase 3 — session discovery via IDiscoverableEcosystem adapters
Add IDiscoverableEcosystem interface with discover() and getCandidatePaths() methods. All 7 adapters implement both methods, moving ~200 lines of ecosystem-specific discovery blocks from SessionDiscovery into the adapters. - ecosystemAdapter.ts: add CandidatePath, DiscoveryResult, IDiscoverableEcosystem - All 7 adapters: implement discover() + getCandidatePaths() - sessionDiscovery.ts: replace 7 discovery blocks + 7 diagnostic blocks with adapter loops (~200 lines removed) - extension.ts + cli/helpers.ts: wire ecosystems into SessionDiscovery deps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e7c731b commit 55f61b5

11 files changed

Lines changed: 298 additions & 213 deletions

cli/src/helpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const _ecosystems: IEcosystemAdapter[] = [
102102

103103
/** Create session discovery instance for CLI */
104104
function createSessionDiscovery(): SessionDiscovery {
105-
return new SessionDiscovery({ log, warn, error, openCode: _openCodeInstance, crush: _crushInstance, continue_: _continueInstance, visualStudio: _visualStudioInstance, claudeCode: _claudeCodeInstance, claudeDesktopCowork: _claudeDesktopCoworkInstance, mistralVibe: _mistralVibeInstance });
105+
return new SessionDiscovery({ log, warn, error, ecosystems: _ecosystems, openCode: _openCodeInstance, crush: _crushInstance, continue_: _continueInstance, visualStudio: _visualStudioInstance, claudeCode: _claudeCodeInstance, claudeDesktopCowork: _claudeDesktopCoworkInstance, mistralVibe: _mistralVibeInstance });
106106
}
107107

108108
/** Discover all session files on this machine */

vscode-extension/src/adapters/claudeCodeAdapter.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as fs from 'fs';
22
import type { ModelUsage } from '../types';
33
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
45
import { ClaudeCodeDataAccess } from '../claudecode';
56

6-
export class ClaudeCodeAdapter implements IEcosystemAdapter {
7+
export class ClaudeCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
78
readonly id = 'claudecode';
89
readonly displayName = 'Claude Code';
910

@@ -47,4 +48,23 @@ export class ClaudeCodeAdapter implements IEcosystemAdapter {
4748
getEditorRoot(_sessionFile: string): string {
4849
return this.claudeCode.getClaudeCodeProjectsDir();
4950
}
51+
52+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
53+
const candidatePaths = this.getCandidatePaths();
54+
const sessionFiles: string[] = [];
55+
try {
56+
const files = this.claudeCode.getClaudeCodeSessionFiles();
57+
if (files.length > 0) {
58+
log(`📄 Found ${files.length} session file(s) in Claude Code (~/.claude/projects)`);
59+
sessionFiles.push(...files);
60+
}
61+
} catch (e) {
62+
log(`Could not read Claude Code session files: ${e}`);
63+
}
64+
return { sessionFiles, candidatePaths };
65+
}
66+
67+
getCandidatePaths(): CandidatePath[] {
68+
return [{ path: this.claudeCode.getClaudeCodeProjectsDir(), source: 'Claude Code' }];
69+
}
5070
}

vscode-extension/src/adapters/claudeDesktopAdapter.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as fs from 'fs';
22
import type { ModelUsage, ChatTurn, ActualUsage } from '../types';
33
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
45
import { ClaudeDesktopCoworkDataAccess } from '../claudedesktop';
56
import { createEmptyContextRefs } from '../tokenEstimation';
67

7-
export class ClaudeDesktopAdapter implements IEcosystemAdapter {
8+
export class ClaudeDesktopAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
89
readonly id = 'claudedesktop';
910
readonly displayName = 'Claude Desktop Cowork';
1011

@@ -54,6 +55,26 @@ export class ClaudeDesktopAdapter implements IEcosystemAdapter {
5455
return this.claudeDesktopCowork.getCoworkBaseDir();
5556
}
5657

58+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
59+
const candidatePaths = this.getCandidatePaths();
60+
const sessionFiles: string[] = [];
61+
try {
62+
const files = this.claudeDesktopCowork.getCoworkSessionFiles();
63+
if (files.length > 0) {
64+
log(`📄 Found ${files.length} session file(s) in Claude Desktop Cowork`);
65+
sessionFiles.push(...files);
66+
}
67+
} catch (e) {
68+
log(`Could not read Claude Desktop Cowork session files: ${e}`);
69+
}
70+
return { sessionFiles, candidatePaths };
71+
}
72+
73+
getCandidatePaths(): CandidatePath[] {
74+
const baseDir = this.claudeDesktopCowork.getCoworkBaseDir();
75+
return baseDir ? [{ path: baseDir, source: 'Claude Desktop (Cowork)' }] : [];
76+
}
77+
5778
async buildTurns(sessionFile: string): Promise<{ turns: ChatTurn[]; actualTokens?: number }> {
5879
const turns: ChatTurn[] = [];
5980
const events = this.claudeDesktopCowork.readCoworkEvents(sessionFile);

vscode-extension/src/adapters/continueAdapter.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as fs from 'fs';
22
import type { ModelUsage, ChatTurn } from '../types';
33
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
45
import { ContinueDataAccess } from '../continue';
56
import { createEmptyContextRefs } from '../tokenEstimation';
67

7-
export class ContinueAdapter implements IEcosystemAdapter {
8+
export class ContinueAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
89
readonly id = 'continue';
910
readonly displayName = 'Continue';
1011

@@ -66,6 +67,25 @@ export class ContinueAdapter implements IEcosystemAdapter {
6667
return this.continue_.getContinueDataDir();
6768
}
6869

70+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
71+
const candidatePaths = this.getCandidatePaths();
72+
const sessionFiles: string[] = [];
73+
try {
74+
const files = this.continue_.getContinueSessionFiles();
75+
if (files.length > 0) {
76+
log(`📄 Found ${files.length} session file(s) in Continue (~/.continue/sessions)`);
77+
sessionFiles.push(...files);
78+
}
79+
} catch (e) {
80+
log(`Could not read Continue session files: ${e}`);
81+
}
82+
return { sessionFiles, candidatePaths };
83+
}
84+
85+
getCandidatePaths(): CandidatePath[] {
86+
return [{ path: this.continue_.getContinueSessionsDir(), source: 'Continue' }];
87+
}
88+
6989
async buildTurns(sessionFile: string): Promise<{ turns: ChatTurn[]; actualTokens?: number }> {
7090
const turns: ChatTurn[] = [];
7191
const continueTurns = this.continue_.buildContinueTurns(sessionFile);

vscode-extension/src/adapters/crushAdapter.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import type { ModelUsage, ChatTurn } from '../types';
44
import type { IEcosystemAdapter } from '../ecosystemAdapter';
5+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
56
import { CrushDataAccess } from '../crush';
67
import { createEmptyContextRefs } from '../tokenEstimation';
78

8-
export class CrushAdapter implements IEcosystemAdapter {
9+
export class CrushAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
910
readonly id = 'crush';
1011
readonly displayName = 'Crush';
1112

@@ -62,6 +63,54 @@ export class CrushAdapter implements IEcosystemAdapter {
6263
return path.dirname(this.crush.getCrushDbPath(sessionFile));
6364
}
6465

66+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
67+
const candidatePaths = this.getCandidatePaths();
68+
const sessionFiles: string[] = [];
69+
70+
try {
71+
const projects = this.crush.readCrushProjects();
72+
log(`📁 Crush: found ${projects.length} project(s) in projects.json`);
73+
74+
const sessionArrays = await Promise.all(
75+
projects.map(async (project) => {
76+
const dbPath = path.join(project.data_dir, 'crush.db');
77+
log(`📁 Checking Crush DB path: ${dbPath}`);
78+
try {
79+
await fs.promises.access(dbPath);
80+
const sessionIds = await this.crush.discoverSessionsInDb(dbPath);
81+
return sessionIds.map(id => path.join(project.data_dir, `crush.db#${id}`));
82+
} catch (e) {
83+
log(`Could not read Crush database for ${project.path}: ${e}`);
84+
}
85+
return [] as string[];
86+
})
87+
);
88+
const total = sessionArrays.reduce((sum, arr) => sum + arr.length, 0);
89+
if (total > 0) {
90+
log(`📄 Found ${total} session(s) in Crush database(s)`);
91+
}
92+
sessionFiles.push(...sessionArrays.flat());
93+
} catch (e) {
94+
log(`Could not read Crush projects: ${e}`);
95+
}
96+
97+
return { sessionFiles, candidatePaths };
98+
}
99+
100+
getCandidatePaths(): CandidatePath[] {
101+
const configDir = this.crush.getCrushConfigDir();
102+
const candidates: CandidatePath[] = [
103+
{ path: path.join(configDir, 'projects.json'), source: 'Crush (projects.json)' },
104+
];
105+
try {
106+
const projects = this.crush.readCrushProjects();
107+
for (const project of projects) {
108+
candidates.push({ path: path.join(project.data_dir, 'crush.db'), source: `Crush (${path.basename(project.path)})` });
109+
}
110+
} catch { /* ignore */ }
111+
return candidates;
112+
}
113+
65114
async buildTurns(sessionFile: string): Promise<{ turns: ChatTurn[]; actualTokens?: number }> {
66115
const turns: ChatTurn[] = [];
67116
const messages = await this.crush.getCrushMessages(sessionFile);

vscode-extension/src/adapters/mistralVibeAdapter.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import * as fs from 'fs';
22
import type { ModelUsage, ChatTurn } from '../types';
33
import type { IEcosystemAdapter } from '../ecosystemAdapter';
4+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
45
import { MistralVibeDataAccess } from '../mistralvibe';
56
import { createEmptyContextRefs } from '../tokenEstimation';
67

7-
export class MistralVibeAdapter implements IEcosystemAdapter {
8+
export class MistralVibeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
89
readonly id = 'mistralvibe';
910
readonly displayName = 'Mistral Vibe';
1011

@@ -48,6 +49,25 @@ export class MistralVibeAdapter implements IEcosystemAdapter {
4849
return this.mistralVibe.getSessionLogDir();
4950
}
5051

52+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
53+
const candidatePaths = this.getCandidatePaths();
54+
const sessionFiles: string[] = [];
55+
try {
56+
const files = this.mistralVibe.discoverSessions();
57+
if (files.length > 0) {
58+
log(`📄 Found ${files.length} session file(s) in Mistral Vibe (~/.vibe/logs/session)`);
59+
sessionFiles.push(...files);
60+
}
61+
} catch (e) {
62+
log(`Could not read Mistral Vibe session files: ${e}`);
63+
}
64+
return { sessionFiles, candidatePaths };
65+
}
66+
67+
getCandidatePaths(): CandidatePath[] {
68+
return [{ path: this.mistralVibe.getSessionLogDir(), source: 'Mistral Vibe' }];
69+
}
70+
5171
async buildTurns(sessionFile: string): Promise<{ turns: ChatTurn[]; actualTokens?: number }> {
5272
const turns: ChatTurn[] = [];
5373
const messages = this.mistralVibe.readSessionMessages(sessionFile);

vscode-extension/src/adapters/openCodeAdapter.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import type { ModelUsage, ChatTurn } from '../types';
44
import type { IEcosystemAdapter } from '../ecosystemAdapter';
5+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
56
import { OpenCodeDataAccess } from '../opencode';
67
import { createEmptyContextRefs } from '../tokenEstimation';
78

8-
export class OpenCodeAdapter implements IEcosystemAdapter {
9+
export class OpenCodeAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
910
readonly id = 'opencode';
1011
readonly displayName = 'OpenCode';
1112

@@ -80,6 +81,74 @@ export class OpenCodeAdapter implements IEcosystemAdapter {
8081
return this.openCode.getOpenCodeDataDir();
8182
}
8283

84+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
85+
const candidatePaths = this.getCandidatePaths();
86+
const sessionFiles: string[] = [];
87+
const dataDir = this.openCode.getOpenCodeDataDir();
88+
const sessionDir = path.join(dataDir, 'storage', 'session');
89+
const dbPath = path.join(dataDir, 'opencode.db');
90+
91+
// Scan JSON session files
92+
log(`📁 Checking OpenCode JSON path: ${sessionDir}`);
93+
log(`📁 Checking OpenCode DB path: ${dbPath}`);
94+
try {
95+
await fs.promises.access(sessionDir);
96+
const scanDir = async (dir: string) => {
97+
try {
98+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
99+
for (const entry of entries) {
100+
if (entry.isDirectory()) {
101+
await scanDir(path.join(dir, entry.name));
102+
} else if (entry.name.startsWith('ses_') && entry.name.endsWith('.json')) {
103+
const fullPath = path.join(dir, entry.name);
104+
try {
105+
const stats = await fs.promises.stat(fullPath);
106+
if (stats.size > 0) { sessionFiles.push(fullPath); }
107+
} catch { /* ignore */ }
108+
}
109+
}
110+
} catch { /* ignore */ }
111+
};
112+
await scanDir(sessionDir);
113+
const jsonCount = sessionFiles.length;
114+
if (jsonCount > 0) {
115+
log(`📄 Found ${jsonCount} session files in OpenCode storage`);
116+
}
117+
} catch { /* sessionDir doesn't exist — skip */ }
118+
119+
// Scan SQLite database for additional sessions (deduplicating against JSON)
120+
try {
121+
await fs.promises.access(dbPath);
122+
const existingIds = new Set(
123+
sessionFiles
124+
.filter(f => this.openCode.isOpenCodeSessionFile(f))
125+
.map(f => this.openCode.getOpenCodeSessionId(f))
126+
.filter(Boolean)
127+
);
128+
const dbSessionIds = await this.openCode.discoverOpenCodeDbSessions();
129+
let dbNewCount = 0;
130+
for (const sessionId of dbSessionIds) {
131+
if (!existingIds.has(sessionId)) {
132+
sessionFiles.push(path.join(dataDir, `opencode.db#${sessionId}`));
133+
dbNewCount++;
134+
}
135+
}
136+
if (dbNewCount > 0) {
137+
log(`📄 Found ${dbNewCount} additional session(s) in OpenCode database`);
138+
}
139+
} catch { /* DB doesn't exist — skip */ }
140+
141+
return { sessionFiles, candidatePaths };
142+
}
143+
144+
getCandidatePaths(): CandidatePath[] {
145+
const dataDir = this.openCode.getOpenCodeDataDir();
146+
return [
147+
{ path: path.join(dataDir, 'storage', 'session'), source: 'OpenCode (JSON)' },
148+
{ path: path.join(dataDir, 'opencode.db'), source: 'OpenCode (DB)' },
149+
];
150+
}
151+
83152
async buildTurns(sessionFile: string): Promise<{ turns: ChatTurn[]; actualTokens?: number }> {
84153
const turns: ChatTurn[] = [];
85154
const messages = await this.openCode.getOpenCodeMessagesForSession(sessionFile);

vscode-extension/src/adapters/visualStudioAdapter.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import type { ModelUsage, ChatTurn } from '../types';
44
import type { IEcosystemAdapter } from '../ecosystemAdapter';
5+
import type { IDiscoverableEcosystem, DiscoveryResult, CandidatePath } from '../ecosystemAdapter';
56
import { VisualStudioDataAccess } from '../visualstudio';
67
import { createEmptyContextRefs } from '../tokenEstimation';
78

8-
export class VisualStudioAdapter implements IEcosystemAdapter {
9+
export class VisualStudioAdapter implements IEcosystemAdapter, IDiscoverableEcosystem {
910
readonly id = 'visualstudio';
1011
readonly displayName = 'Visual Studio';
1112

@@ -61,6 +62,25 @@ export class VisualStudioAdapter implements IEcosystemAdapter {
6162

6263
readonly skipBackendSync = true;
6364

65+
async discover(log: (msg: string) => void): Promise<DiscoveryResult> {
66+
const candidatePaths = this.getCandidatePaths();
67+
const sessionFiles: string[] = [];
68+
try {
69+
const sessions = this.visualStudio.discoverSessions();
70+
if (sessions.length > 0) {
71+
log(`📄 Found ${sessions.length} session file(s) in Visual Studio Copilot`);
72+
sessionFiles.push(...sessions);
73+
}
74+
} catch (e) {
75+
log(`Could not read Visual Studio session files: ${e}`);
76+
}
77+
return { sessionFiles, candidatePaths };
78+
}
79+
80+
getCandidatePaths(): CandidatePath[] {
81+
return [{ path: this.visualStudio.getLogDir(), source: 'Visual Studio (log dir)' }];
82+
}
83+
6484
getRawFileContent(sessionFile: string): string {
6585
const objects = this.visualStudio.decodeSessionFile(sessionFile);
6686
const readable = objects.map((obj: any, i: number) => i === 0 ? obj : obj?.[1] ?? obj);

0 commit comments

Comments
 (0)