mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user