From dfde70b519e8e341f838f47121a2fdd009251d3e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:21:43 -0700 Subject: [PATCH] fix(ai-vault): list Claude sessions from a wrapper or launch-setting CLAUDE_CONFIG_DIR (#33751) * fix(ai-vault): list Claude sessions from a wrapper or launch-setting CLAUDE_CONFIG_DIR History, usage and transcript lookup now also scan Claude config folders Orca saw in Claude hook transcript paths (kept across restarts) and the CLAUDE_CONFIG_DIR set in Orca's Claude launch environment. Fixes #23505 * fix(ai-vault): normalize observed Claude folders and cache missing ones Adds tests that only Claude hooks record a folder (not same-layout CodeBuddy), non-transcript paths are ignored, and the 32-entry cap holds. --- ...rver-claude-config-dir-observation.test.ts | 58 +++++++++++++++ .../server/server-ingest-normalization.ts | 6 ++ ...ude-credential-persistence-ratchet.test.ts | 2 + .../claude-observed-config-dirs.ts | 71 +++++++++++++++++++ .../claude-profile-installed-router.ts | 23 ++++-- .../claude-profile-router.test.ts | 42 +++++++++++ .../runtime-auth-service.test.ts | 1 + .../claude-accounts/runtime-auth-service.ts | 7 ++ 8 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 src/main/agent-hooks/server-claude-config-dir-observation.test.ts create mode 100644 src/main/claude-accounts/claude-observed-config-dirs.ts diff --git a/src/main/agent-hooks/server-claude-config-dir-observation.test.ts b/src/main/agent-hooks/server-claude-config-dir-observation.test.ts new file mode 100644 index 00000000000..f94b46651b1 --- /dev/null +++ b/src/main/agent-hooks/server-claude-config-dir-observation.test.ts @@ -0,0 +1,58 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { AgentHookServer } from './server' +import { buildBody, GOOD_PANE, PANE, postHookEvent } from './server.test-fixtures' +import { ClaudeObservedConfigDirs } from '../claude-accounts/claude-observed-config-dirs' +import { installClaudeObservedConfigDirs } from '../claude-accounts/claude-profile-installed-router' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) +vi.mock('../../shared/agent-process-presence-probe', () => ({ + probeAgentProcessPresence: async () => 'live' +})) + +let root: string | undefined +let server: AgentHookServer | undefined +afterEach(() => { + server?.stop() + installClaudeObservedConfigDirs(undefined) + if (root) { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('records the Claude config folder from Claude hooks only, not same-layout CodeBuddy hooks', async () => { + root = mkdtempSync(join(tmpdir(), 'orca-claude-observed-')) + const transcript = (home: string) => { + mkdirSync(join(root!, home, 'projects', '-repo'), { recursive: true }) + return join(root!, home, 'projects', '-repo', 'id.jsonl') + } + const dirs = new ClaudeObservedConfigDirs(join(root, 'observed.json'), () => ({})) + installClaudeObservedConfigDirs(dirs) + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: join(root, 'orca') }) + const post = (path: string, transcriptPath: string, paneKey = PANE) => + postHookEvent( + server!, + buildBody( + { + hook_event_name: 'UserPromptSubmit', + session_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + transcript_path: transcriptPath, + prompt: 'hi' + }, + { + paneKey, + tabId: paneKey.split(':')[0], + agentProcess: JSON.stringify({ pid: 4001, platform: process.platform, startTime: 'x' }) + } + ), + path + ) + await post('/hook/claude', transcript('.claude-work')) + // Separate pane: one pane's claim does not depend on the other agent's. + await post('/hook/codebuddy', transcript('.codebuddy'), GOOD_PANE) + expect(dirs.list()).toEqual([join(root, '.claude-work')]) +}) diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts index 96a945a6c10..fbc2de9826e 100644 --- a/src/main/agent-hooks/server/server-ingest-normalization.ts +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -9,6 +9,7 @@ import { } from '../../../shared/agent-hook-listener/opencode-session-registry' import type { NormalizedLocalHook } from './server-types' import { AgentHookServerTmuxStatus } from './server-tmux-status' +import { getClaudeObservedConfigDirs } from '../../claude-accounts/claude-profile-installed-router' export abstract class AgentHookServerIngestNormalization extends AgentHookServerTmuxStatus { protected setClaudeBackgroundEvidence( @@ -69,6 +70,11 @@ export abstract class AgentHookServerIngestNormalization extends AgentHookServer const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) const event = normalizeHookPayload(this.state, source, body, this.env) + const transcriptPath = event?.providerSession?.transcriptPath + if (transcriptPath) { + // A wrapper's CLAUDE_CONFIG_DIR is only visible here; History scans it too (#23505). + getClaudeObservedConfigDirs()?.observeTranscriptPath(transcriptPath) + } const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) diff --git a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts index 73269517203..f13123a2447 100644 --- a/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts +++ b/src/main/claude-accounts/claude-credential-persistence-ratchet.test.ts @@ -29,6 +29,8 @@ const fsAllowances: Record = { 'symlinkSync', 'unlinkSync' ], + // Stores Claude config folder paths only. + 'claude-accounts/claude-observed-config-dirs.ts': ['writeFileAtomically'], 'claude-accounts/claude-profile-paths.ts': ['mkdirSync', 'writeFileAtomically'], 'claude-accounts/claude-profile-router.ts': ['mkdirSync', 'rmSync', 'writeFileAtomically'], 'claude-accounts/claude-account-folder.ts': ['rmSync', 'rmdirSync', 'unlinkSync'], diff --git a/src/main/claude-accounts/claude-observed-config-dirs.ts b/src/main/claude-accounts/claude-observed-config-dirs.ts new file mode 100644 index 00000000000..ada10b0dd25 --- /dev/null +++ b/src/main/claude-accounts/claude-observed-config-dirs.ts @@ -0,0 +1,71 @@ +import { readFileSync } from 'node:fs' +import { basename, dirname, isAbsolute, resolve } from 'node:path' +import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override' +import type { GlobalSettings } from '../../shared/global-settings-types' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { isDirectory } from './claude-profile-homes' + +const MAX_OBSERVED_CONFIG_DIRS = 32 + +/** Claude config folders Orca saw in use but the login shell does not name, e.g. a wrapper's CLAUDE_CONFIG_DIR. */ +export class ClaudeObservedConfigDirs { + private observed: string[] | undefined + // WSL/remote paths name no folder here; skip re-statting them on every hook. + private readonly missing = new Set() + + constructor( + private readonly filePath: string, + private readonly getSettings: () => Pick + ) {} + + /** Orca's Claude launch-environment setting first, then folders seen in hook transcript paths. */ + list(): string[] { + this.observed ??= this.load() + const fromSettings = resolveAbsoluteDirOverride( + this.getSettings().agentDefaultEnv?.claude?.CLAUDE_CONFIG_DIR, + '' + ) + return fromSettings ? [fromSettings, ...this.observed] : this.observed + } + + /** Claude writes `/projects//.jsonl`; other shapes are ignored. */ + observeTranscriptPath(transcriptPath: string): void { + if (!isAbsolute(transcriptPath)) { + return + } + const projectsDir = dirname(dirname(resolve(transcriptPath))) + const configDir = dirname(projectsDir) + this.observed ??= this.load() + if ( + basename(projectsDir) !== 'projects' || + this.observed.includes(configDir) || + this.missing.has(configDir) + ) { + return + } + if (!isDirectory(projectsDir)) { + // A hooked config folder exists locally even before Claude creates `projects`. + if (!isDirectory(configDir)) { + this.missing.add(configDir) + } + return + } + this.observed = [configDir, ...this.observed].slice(0, MAX_OBSERVED_CONFIG_DIRS) + try { + writeFileAtomically(this.filePath, JSON.stringify(this.observed), { mode: 0o600 }) + } catch (error) { + console.warn('[claude-profile] Could not save observed Claude config folders:', error) + } + } + + private load(): string[] { + try { + const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + return Array.isArray(parsed) + ? parsed.filter((dir): dir is string => typeof dir === 'string' && isAbsolute(dir)) + : [] + } catch { + return [] + } + } +} diff --git a/src/main/claude-accounts/claude-profile-installed-router.ts b/src/main/claude-accounts/claude-profile-installed-router.ts index ffaa479e785..4ac6ad2969e 100644 --- a/src/main/claude-accounts/claude-profile-installed-router.ts +++ b/src/main/claude-accounts/claude-profile-installed-router.ts @@ -4,6 +4,7 @@ import { isDirectory } from './claude-profile-homes' import type { ClaudeProfileRouter } from './claude-profile-router' import type { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import type { ClaudeAccountSelectionTarget } from './runtime-selection' +import type { ClaudeObservedConfigDirs } from './claude-observed-config-dirs' let installed: ClaudeProfileRouter | undefined @@ -16,6 +17,16 @@ export function getClaudeProfileRouter(): ClaudeProfileRouter | undefined { return installed } +let installedObserved: ClaudeObservedConfigDirs | undefined + +export function installClaudeObservedConfigDirs(dirs: ClaudeObservedConfigDirs | undefined): void { + installedObserved = dirs +} + +export function getClaudeObservedConfigDirs(): ClaudeObservedConfigDirs | undefined { + return installedObserved +} + let installedWsl: ClaudeWslProfileRouter | undefined export function installClaudeWslProfileRouter(router: ClaudeWslProfileRouter | undefined): void { @@ -34,9 +45,13 @@ export function withClaudeProfileTerminalEnv return profileEnv ? { ...env, ...profileEnv } : env } -/** Account homes plus the user's CLAUDE_CONFIG_DIR, which a Dock launch only learns from the login shell. */ +/** The user's CLAUDE_CONFIG_DIR from the login shell, folders Orca saw Claude use, and account homes. */ export function claudeProfileHistoryDirs(surface: 'projects' | 'transcripts'): string[] { - const userConfigDir = installed?.userConfigDir() - const homes = [...(userConfigDir ? [userConfigDir] : []), ...(installed?.accountHomes() ?? [])] - return homes.map((home) => join(home, surface)).filter(isDirectory) + const homes = [ + installed?.userConfigDir(), + ...(installedObserved?.list() ?? []), + ...(installed?.accountHomes() ?? []) + ].filter((home): home is string => Boolean(home)) + const dirs = homes.map((home) => join(home, surface)).filter(isDirectory) + return dirs.filter((dir, index) => dirs.indexOf(dir) === index) } diff --git a/src/main/claude-accounts/claude-profile-router.test.ts b/src/main/claude-accounts/claude-profile-router.test.ts index 6f1fec2d4cd..f0ee60593ba 100644 --- a/src/main/claude-accounts/claude-profile-router.test.ts +++ b/src/main/claude-accounts/claude-profile-router.test.ts @@ -30,8 +30,11 @@ import { getPosixClaudeShellFunction } from '../../shared/claude-shell-function' import { ClaudeProfileRouter, type ClaudeProfileRouterSettings } from './claude-profile-router' import { claudeProfileHistoryDirs, + getClaudeObservedConfigDirs, + installClaudeObservedConfigDirs, installClaudeProfileRouter } from './claude-profile-installed-router' +import { ClaudeObservedConfigDirs } from './claude-observed-config-dirs' const shell = vi.hoisted(() => ({ hang: false })) vi.mock('../startup/login-shell-environment', () => ({ @@ -52,6 +55,7 @@ afterEach(() => { vi.useRealTimers() shell.hang = false installClaudeProfileRouter(undefined) + installClaudeObservedConfigDirs(undefined) roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })) }) @@ -555,4 +559,42 @@ describe('ClaudeProfileRouter', () => { installClaudeProfileRouter(fixture({ CLAUDE_CONFIG_DIR: configDir }).router) expect(claudeProfileHistoryDirs('projects')).toEqual([join(configDir, 'projects')]) }) + + it('lists history under a wrapper CLAUDE_CONFIG_DIR seen in a hook, after restart too', () => { + const f = fixture() + const projects = join(f.root, '.claude-work', 'projects') + const file = join(f.root, 'observed.json') + mkdirSync(join(projects, '-proj'), { recursive: true }) + const observe = () => new ClaudeObservedConfigDirs(file, () => ({})) + installClaudeObservedConfigDirs(observe()) + expect(claudeProfileHistoryDirs('projects')).toEqual([]) + getClaudeObservedConfigDirs()?.observeTranscriptPath(join(projects, '-proj', 'id.jsonl')) + installClaudeObservedConfigDirs(observe()) + expect(claudeProfileHistoryDirs('projects')).toEqual([projects]) + }) + + it("lists history under the CLAUDE_CONFIG_DIR set in Orca's Claude launch environment", () => { + const f = fixture() + const configDir = join(f.root, '.claude-settings') + mkdirSync(join(configDir, 'projects'), { recursive: true }) + const agentDefaultEnv = { claude: { CLAUDE_CONFIG_DIR: configDir } } + installClaudeObservedConfigDirs( + new ClaudeObservedConfigDirs(join(f.root, 'observed.json'), () => ({ agentDefaultEnv })) + ) + expect(claudeProfileHistoryDirs('projects')).toEqual([join(configDir, 'projects')]) + }) + + it('ignores non-transcript paths and keeps at most 32 observed folders', () => { + const f = fixture() + const dirs = new ClaudeObservedConfigDirs(join(f.root, 'observed.json'), () => ({})) + mkdirSync(join(f.root, 'other', 'sessions', '-proj'), { recursive: true }) + dirs.observeTranscriptPath(join(f.root, 'other', 'sessions', '-proj', 'id.jsonl')) + expect(dirs.list()).toEqual([]) + for (let index = 0; index < 33; index += 1) { + mkdirSync(join(f.root, `c${index}`, 'projects', '-proj'), { recursive: true }) + dirs.observeTranscriptPath(join(f.root, `c${index}`, 'projects', '-proj', 'id.jsonl')) + } + expect(dirs.list()).toHaveLength(32) + expect(dirs.list()[0]).toBe(join(f.root, 'c32')) + }) }) diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index ed9a278bed4..2b8dc727f0b 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -33,6 +33,7 @@ vi.mock('./claude-profile-wsl-router', () => ({ } })) vi.mock('./claude-profile-installed-router', () => ({ + installClaudeObservedConfigDirs: () => {}, installClaudeProfileRouter: () => {}, installClaudeWslProfileRouter: () => {} })) diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 877a84a0e84..fc51c4612cf 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -8,9 +8,11 @@ import { getDefaultWslDistro, getWslHome } from '../wsl' import { ClaudeProfileRouter } from './claude-profile-router' import { ClaudeWslProfileRouter } from './claude-profile-wsl-router' import { + installClaudeObservedConfigDirs, installClaudeProfileRouter, installClaudeWslProfileRouter } from './claude-profile-installed-router' +import { ClaudeObservedConfigDirs } from './claude-observed-config-dirs' import type { ClaudeAccountSelectionTarget } from './runtime-selection' import type { ClaudeRuntimeAuthPreparation } from './runtime-auth/runtime-auth-types' @@ -32,6 +34,11 @@ export class ClaudeRuntimeAuthService { const args = { getSettings: () => store.getSettings(), dataRoot: this.dataRoot } this.router = new ClaudeProfileRouter(args) installClaudeProfileRouter(this.router) + installClaudeObservedConfigDirs( + new ClaudeObservedConfigDirs(join(this.dataRoot, 'claude-observed-config-dirs.json'), () => + store.getSettings() + ) + ) this.wslRouter = process.platform === 'win32' ? new ClaudeWslProfileRouter(args) : undefined installClaudeWslProfileRouter(this.wslRouter) void this.publishAll().catch((error: unknown) => {