Skip to content

Commit 1a09010

Browse files
committed
feat: detect agent config dirs via WSL env vars
Resolve CLAUDE_CONFIG_DIR and CODEX_HOME from WSL environment at startup (parallel with $HOME resolution). Adapters use detected paths with fallback to defaults (~/.claude, ~/.codex). - log-adapters.ts: AgentEnvContext interface, Claude/Codex adapters accept env context in getLogDirs() - cost-tracker.ts: resolves CLAUDE_CONFIG_DIR + CODEX_HOME via wslExec, passes to adapters during discovery - skill-scanner.ts: resolveCodexHome() uses $CODEX_HOME for skills path - 4 new tests for env var override paths - Comments documenting Goose, OpenCode, Gemini CLI, Amazon Q env vars for future adapter development Co-Authored-By: Rooty
1 parent 3b8daba commit 1a09010

5 files changed

Lines changed: 120 additions & 12 deletions

File tree

src/main/cost-tracker.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ function makeRoutingMock(overrides?: {
8787
const cmd = Array.isArray(args) ? args.join(' ') : ''
8888
if (cmd.includes('echo "$HOME"')) {
8989
cb(null, `${home}\n`, '')
90+
} else if (cmd.includes('CLAUDE_CONFIG_DIR') || cmd.includes('CODEX_HOME')) {
91+
// Agent env var resolution — return empty (use defaults)
92+
cb(null, '\n', '')
9093
} else if (cmd.includes('find ')) {
9194
cb(null, findResult, '')
9295
} else if (cmd.includes('head ')) {

src/main/cost-tracker.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { BrowserWindow } from 'electron'
1010
import { execFile } from 'child_process'
1111
import { toWslPath } from './wsl-utils'
1212
import { createLogger } from './logger'
13-
import type { LogAdapter, TokenUsage } from './log-adapters'
13+
import type { AgentEnvContext, LogAdapter, TokenUsage } from './log-adapters'
1414
import { ZERO_USAGE } from './log-adapters'
1515

1616
const log = createLogger('cost-tracker')
@@ -106,18 +106,50 @@ export function createCostTracker(mainWindow: BrowserWindow, adapters: LogAdapte
106106
return ''
107107
})
108108

109+
// Resolve agent config env vars from WSL (CLAUDE_CONFIG_DIR, CODEX_HOME).
110+
// These override the default ~/.claude and ~/.codex base paths.
111+
// Resolved in parallel with $HOME since they're independent.
112+
let cachedEnv: AgentEnvContext | null = null
113+
const envReady: Promise<AgentEnvContext> = Promise.all([
114+
// eslint-disable-next-line no-template-curly-in-string -- bash variable expansion, not JS
115+
wslExec('echo "${CLAUDE_CONFIG_DIR:-}"')
116+
.then((o) => o.trim())
117+
.catch(() => ''),
118+
// eslint-disable-next-line no-template-curly-in-string -- bash variable expansion, not JS
119+
wslExec('echo "${CODEX_HOME:-}"')
120+
.then((o) => o.trim())
121+
.catch(() => ''),
122+
])
123+
.then(([rawClaude, rawCodex]) => {
124+
const claudeConfigDir = rawClaude || undefined
125+
const codexHome = rawCodex || undefined
126+
const env: AgentEnvContext = { claudeConfigDir, codexHome }
127+
cachedEnv = env
128+
log.info('Resolved WSL agent env vars', { claudeConfigDir, codexHome })
129+
return env
130+
})
131+
.catch((err) => {
132+
log.warn('Failed to resolve WSL agent env vars — using defaults', {
133+
err: String(err),
134+
})
135+
const env: AgentEnvContext = {}
136+
cachedEnv = env
137+
return env
138+
})
139+
109140
// ── Discovery ───────────────────────────────────────────────────
110141

111142
function startDiscovery(session: BoundSession): void {
112-
const rawDirs = session.adapter.getLogDirs(session.projectPath)
113143
const pattern = session.adapter.getFilePattern()
114144

115-
// Use cached $HOME if available; otherwise wait for the initial resolution.
145+
// Use cached values if available; otherwise wait for initial resolution.
116146
const homePromise = cachedHome !== null ? Promise.resolve(cachedHome) : homeReady
147+
const envPromise = cachedEnv !== null ? Promise.resolve(cachedEnv) : envReady
117148

118-
homePromise
119-
.then((home) => {
149+
Promise.all([homePromise, envPromise])
150+
.then(([home, env]) => {
120151
if (!sessions.has(session.sessionId)) return
152+
const rawDirs = session.adapter.getLogDirs(session.projectPath, env)
121153
const dirs = home
122154
? rawDirs.map((d) => (d.startsWith('~') ? home + d.slice(1) : d))
123155
: rawDirs.filter((d) => !d.startsWith('~'))
@@ -130,7 +162,7 @@ export function createCostTracker(mainWindow: BrowserWindow, adapters: LogAdapte
130162
runDiscoveryLoop(session, dirs, pattern)
131163
})
132164
.catch(() => {
133-
/* homeReady never rejects (has .catch), but guard defensively */
165+
/* homeReady/envReady never reject (have .catch), but guard defensively */
134166
})
135167
}
136168

src/main/log-adapters.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ describe('ClaudeAdapter', () => {
8080
expect(first).toMatch(/~\/\.claude\/projects\/-home-rooty-my-project\/$/)
8181
})
8282

83+
it('getLogDirs uses CLAUDE_CONFIG_DIR from env context', () => {
84+
const dirs = adapter.getLogDirs('/home/rooty/my-project', {
85+
claudeConfigDir: '/custom/claude-config',
86+
})
87+
expect(dirs).toHaveLength(1)
88+
const first = dirs[0]
89+
if (!first) throw new Error('Expected first dir')
90+
expect(first).toMatch(/^\/custom\/claude-config\/projects\/-home-rooty-my-project\/$/)
91+
// Should NOT contain tilde
92+
expect(first).not.toContain('~')
93+
})
94+
95+
it('getLogDirs falls back to ~/.claude when env context has no claudeConfigDir', () => {
96+
const dirs = adapter.getLogDirs('/home/rooty/my-project', {})
97+
expect(dirs).toHaveLength(1)
98+
const first = dirs[0]
99+
if (!first) throw new Error('Expected first dir')
100+
expect(first).toMatch(/^~\/\.claude\/projects\//)
101+
})
102+
83103
it('getFilePattern returns "*.jsonl"', () => {
84104
expect(adapter.getFilePattern()).toBe('*.jsonl')
85105
})
@@ -231,6 +251,24 @@ describe('CodexAdapter', () => {
231251
expect(dir).toMatch(/\d{4}\/\d{2}\/\d{2}$/)
232252
})
233253

254+
it('getLogDirs uses CODEX_HOME from env context', () => {
255+
const dirs = adapter.getLogDirs('/home/rooty/any', { codexHome: '/custom/codex' })
256+
expect(dirs).toHaveLength(1)
257+
const dir = dirs[0]
258+
if (!dir) throw new Error('Expected dir')
259+
expect(dir).toMatch(/^\/custom\/codex\/sessions\/\d{4}\/\d{2}\/\d{2}$/)
260+
// Should NOT contain tilde
261+
expect(dir).not.toContain('~')
262+
})
263+
264+
it('getLogDirs falls back to ~/.codex when env context has no codexHome', () => {
265+
const dirs = adapter.getLogDirs('/home/rooty/any', {})
266+
expect(dirs).toHaveLength(1)
267+
const dir = dirs[0]
268+
if (!dir) throw new Error('Expected dir')
269+
expect(dir).toContain('~/.codex/sessions/')
270+
})
271+
234272
it('getFilePattern returns "rollout-*.jsonl"', () => {
235273
expect(adapter.getFilePattern()).toBe('rollout-*.jsonl')
236274
})

src/main/log-adapters.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ import { createLogger } from './logger'
1010

1111
const log = createLogger('log-adapters')
1212

13+
// Future adapter env vars:
14+
// Goose: GOOSE_CONFIG_DIR (config), data at ~/.local/share/goose/
15+
// OpenCode: OPENCODE_DATA_DIR (data), OPENCODE_CONFIG_DIR (config)
16+
// Gemini CLI: no env var yet (requested: github.com/google-gemini/gemini-cli/issues/2815)
17+
// Amazon Q: no env var, free service (no cost tracking needed)
18+
1319
/** Track which schema warnings have already been emitted (avoid flooding). */
1420
const warnedSchemas = new Set<string>()
1521

@@ -34,10 +40,22 @@ export const ZERO_USAGE: Readonly<TokenUsage> = Object.freeze({
3440
totalCostUsd: 0,
3541
})
3642

43+
/**
44+
* Resolved WSL environment variables for agent config directories.
45+
* These are read from the WSL environment (via wslExec), NOT from Node's process.env.
46+
* Each field is the resolved absolute path, or undefined if the env var is unset.
47+
*/
48+
export interface AgentEnvContext {
49+
/** $CLAUDE_CONFIG_DIR — overrides ~/.claude */
50+
claudeConfigDir?: string | undefined
51+
/** $CODEX_HOME — overrides ~/.codex */
52+
codexHome?: string | undefined
53+
}
54+
3755
export interface LogAdapter {
3856
agent: string
3957
/** Return candidate directories (tilde-prefixed) to search for log files. */
40-
getLogDirs(projectPath: string): string[]
58+
getLogDirs(projectPath: string, env?: AgentEnvContext): string[]
4159
/** Glob pattern for log files within the dir. */
4260
getFilePattern(): string
4361
/**
@@ -115,14 +133,15 @@ export function createClaudeAdapter(): LogAdapter {
115133
return {
116134
agent: 'claude-code',
117135

118-
getLogDirs(projectPath: string): string[] {
136+
getLogDirs(projectPath: string, env?: AgentEnvContext): string[] {
119137
// Replace every `/` with `-` to match Claude's path-slug convention.
120138
const pathSlug = projectPath.replace(/\//g, '-')
121139
// JSONL files live directly in the project slug directory (no sessions/ subdir).
122140
// Only search the project-specific directory — the old fallback
123141
// `~/.claude/projects/` recursively scanned ALL projects, returning hundreds
124142
// of candidates and causing discovery timeouts with multiple concurrent sessions.
125-
return [`~/.claude/projects/${pathSlug}/`]
143+
const claudeHome = env?.claudeConfigDir ?? '~/.claude'
144+
return [`${claudeHome}/projects/${pathSlug}/`]
126145
},
127146

128147
getFilePattern(): string {
@@ -223,8 +242,9 @@ export function createCodexAdapter(): LogAdapter {
223242
return {
224243
agent: 'codex',
225244

226-
getLogDirs(_projectPath: string): string[] {
227-
return [`~/.codex/sessions/${todayDateDir()}`]
245+
getLogDirs(_projectPath: string, env?: AgentEnvContext): string[] {
246+
const codexHome = env?.codexHome ?? '~/.codex'
247+
return [`${codexHome}/sessions/${todayDateDir()}`]
228248
},
229249

230250
getFilePattern(): string {

src/main/skill-scanner.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ interface SkillCache {
4242

4343
let globalCache: SkillCache | null = null
4444
const cachedHomePaths = new Map<string, string>()
45+
const cachedCodexHomes = new Map<string, string>()
4546
const projectCache = new Map<string, SkillCache>()
4647
const inFlight = new Map<string, Promise<SkillCache>>()
4748

@@ -86,6 +87,18 @@ async function resolveHomePath(distro: string): Promise<string | null> {
8687
return home
8788
}
8889

90+
// ── Cached $CODEX_HOME resolution ────────────────────────────────
91+
92+
async function resolveCodexHome(distro: string, homePath: string): Promise<string> {
93+
const cached = cachedCodexHomes.get(distro)
94+
if (cached) return cached
95+
// eslint-disable-next-line no-template-curly-in-string -- bash variable expansion, not JS
96+
const output = await wslExec('echo "${CODEX_HOME:-}"', distro)
97+
const resolved = output?.trim() || `${homePath}/.codex`
98+
cachedCodexHomes.set(distro, resolved)
99+
return resolved
100+
}
101+
89102
// ── Frontmatter parsing ───────────────────────────────────────────
90103

91104
export function parseFrontmatter(content: string, dirName: string): ParsedFrontmatter | null {
@@ -270,7 +283,8 @@ export async function getGlobalSkills(
270283
return { skills: [], skipped: 0, timestamp: Date.now() }
271284
}
272285

273-
const globalSkillsPath = `${homePath}/.codex/skills`
286+
const codexHome = await resolveCodexHome(resolvedDistro, homePath)
287+
const globalSkillsPath = `${codexHome}/skills`
274288
const key = cacheKey('global', globalSkillsPath, resolvedDistro)
275289

276290
// Deduplicate in-flight requests
@@ -417,6 +431,7 @@ export function invalidateProjectCache(projectPath: string): void {
417431
export function invalidateAllCaches(): void {
418432
globalCache = null
419433
cachedHomePaths.clear()
434+
cachedCodexHomes.clear()
420435
projectCache.clear()
421436
inFlight.clear()
422437
}

0 commit comments

Comments
 (0)