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.
This commit is contained in:
Neil
2026-10-25 20:21:43 -07:00
committed by GitHub
parent 165412b9ac
commit dfde70b519
8 changed files with 206 additions and 4 deletions
@@ -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')])
})
@@ -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)
@@ -29,6 +29,8 @@ const fsAllowances: Record<string, readonly string[]> = {
'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'],
@@ -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<string>()
constructor(
private readonly filePath: string,
private readonly getSettings: () => Pick<GlobalSettings, 'agentDefaultEnv'>
) {}
/** 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 `<config>/projects/<encoded-cwd>/<id>.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 []
}
}
}
@@ -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<Env extends Record<string, string>
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)
}
@@ -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'))
})
})
@@ -33,6 +33,7 @@ vi.mock('./claude-profile-wsl-router', () => ({
}
}))
vi.mock('./claude-profile-installed-router', () => ({
installClaudeObservedConfigDirs: () => {},
installClaudeProfileRouter: () => {},
installClaudeWslProfileRouter: () => {}
}))
@@ -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) => {