diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 9cf3779c03c..43eac7cb024 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -6,6 +6,7 @@ "../src/main/agent-state-file-reader.ts", "../src/main/agent-hooks/grok-replay-guard.ts", "../src/main/claude/hook-script.ts", + "../src/main/claude/claude-session-end-hook-capability.ts", "../src/main/agent-hooks/hook-stdin-contract.ts", "../src/main/agent-hooks/hook-post-command.ts", "../src/main/agent-hooks/hook-config-write-path.ts", diff --git a/src/main/agent-hooks/local-agent-cli-presence.test.ts b/src/main/agent-hooks/local-agent-cli-presence.test.ts index 15f474a9cc7..d8bb85bc405 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.test.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.test.ts @@ -41,6 +41,7 @@ describe('detectLocalManagedAgentCliPresence', () => { ) expect(result.codex?.state).toBe('found') + expect(result.codex).toEqual({ state: 'found', executablePath: '/bin/codex' }) expect(result.claude?.state).toBe('missing') expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([ '/bin/codex', @@ -63,6 +64,7 @@ describe('detectLocalManagedAgentCliPresence', () => { ) expect(result.codex?.state).toBe('found') + expect(result.codex).toEqual({ state: 'found', executablePath: '/custom/bin/codex' }) expect(probe).toHaveBeenCalledWith('/custom/bin/codex') }) @@ -81,6 +83,7 @@ describe('detectLocalManagedAgentCliPresence', () => { ) expect(result.claude?.state).toBe('found') + expect(result.claude).toEqual({ state: 'found', executablePath: overridePath }) expect(probe).toHaveBeenCalledWith(overridePath) }) diff --git a/src/main/agent-hooks/local-agent-cli-presence.ts b/src/main/agent-hooks/local-agent-cli-presence.ts index 5810362c27a..897e4c08b50 100644 --- a/src/main/agent-hooks/local-agent-cli-presence.ts +++ b/src/main/agent-hooks/local-agent-cli-presence.ts @@ -14,7 +14,10 @@ import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-pa export type LocalCliPresenceState = 'found' | 'missing' | 'unknown' export type LocalCliPresenceByAgent = Partial< - Record + Record< + AgentHookTarget, + { state: 'found'; executablePath: string } | { state: Exclude } + > > type FileProbe = { @@ -113,18 +116,18 @@ async function probePathCandidate( platform: NodeJS.Platform, fileProbe: FileProbe, pathExt?: string -): Promise { +): Promise { if (!isSafeExecutableBasename(candidate)) { - return false + return null } for (const dir of dirs) { for (const fileName of candidateFileNames(candidate, platform, pathExt)) { if (await fileProbe.isExecutableFile(pathApiForPlatform(platform).join(dir, fileName))) { - return true + return pathApiForPlatform(platform).join(dir, fileName) } } } - return false + return null } function isPlatformAbsolutePath(candidate: string, platform: NodeJS.Platform): boolean { @@ -171,10 +174,17 @@ export async function detectLocalManagedAgentCliPresence( candidates.add(override) } } - const found = new Set() + const found = new Map() for (const candidate of candidates) { - if (await probePathCandidate(candidate, dirs, platform, fileProbe, options.pathExt)) { - found.add(candidate) + const executablePath = await probePathCandidate( + candidate, + dirs, + platform, + fileProbe, + options.pathExt + ) + if (executablePath) { + found.set(candidate, executablePath) } } const result: LocalCliPresenceByAgent = {} @@ -187,13 +197,16 @@ export async function detectLocalManagedAgentCliPresence( continue } result[target.agent] = (await fileProbe.isExecutableFile(expanded)) - ? { state: 'found' } + ? { state: 'found', executablePath: expanded } : { state: 'missing' } continue } const targetCandidates = [...target.executableCandidates, ...(override ? [override] : [])] - result[target.agent] = targetCandidates.some((candidate) => found.has(candidate)) - ? { state: 'found' } + const executablePath = targetCandidates + .map((candidate) => found.get(candidate)) + .find((candidate): candidate is string => candidate !== undefined) + result[target.agent] = executablePath + ? { state: 'found', executablePath } : { state: 'missing' } } return result diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index dd77b5e8d20..625cc06f505 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -11,13 +11,18 @@ const mocks = vi.hoisted(() => ({ statusClaude: vi.fn(), statusCodex: vi.fn(), refreshClaude: vi.fn(), - refreshCodex: vi.fn() + refreshCodex: vi.fn(), + probeClaudeVersion: vi.fn() })) vi.mock('./local-agent-cli-presence', () => ({ detectLocalManagedAgentCliPresence: mocks.detect })) +vi.mock('../claude/claude-session-end-hook-capability', () => ({ + probeClaudeCliVersion: mocks.probeClaudeVersion +})) + vi.mock('./managed-agent-hook-registry', () => ({ MANAGED_AGENT_HOOK_INSTALLERS: [ ['claude', mocks.installClaude], @@ -71,6 +76,7 @@ describe('managed agent hook controls', () => { mocks.removeCodexAsync.mockResolvedValue(status('codex', 'not_installed')) mocks.refreshClaude.mockResolvedValue(undefined) mocks.refreshCodex.mockResolvedValue(undefined) + mocks.probeClaudeVersion.mockResolvedValue(null) }) it('installs only agents with positively detected CLIs', async () => { @@ -159,6 +165,19 @@ describe('managed agent hook controls', () => { ]) }) + it('forwards the detected Claude version to its installer', async () => { + mocks.detect.mockResolvedValue({ + claude: { state: 'found', executablePath: '/opt/bin/claude' }, + codex: { state: 'missing' } + }) + mocks.probeClaudeVersion.mockResolvedValue('2.1.261') + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.probeClaudeVersion).toHaveBeenCalledWith('/opt/bin/claude') + expect(mocks.installClaude).toHaveBeenCalledWith({ cliVersion: '2.1.261' }) + }) + it('only refreshes scripts for the selected agents', async () => { mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 6c875257089..7edfd94c7c8 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -5,6 +5,7 @@ import { } from '../../shared/managed-agent-hook-targets' import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' +import { probeClaudeCliVersion } from '../claude/claude-session-end-hook-capability' import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_ASYNC_REMOVERS, @@ -12,7 +13,8 @@ import { MANAGED_AGENT_HOOK_REMOVERS, MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, MANAGED_AGENT_HOOK_STATUS_READERS, - type ManagedAgentHookInstaller + type ManagedAgentHookInstaller, + type ManagedAgentHookInstallOptions } from './managed-agent-hook-registry' export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry' @@ -112,11 +114,11 @@ function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookI async function runInstaller( entry: ManagedAgentHookInstaller, onInstallError: InstallOptions['onInstallError'], - userInitiated?: boolean + options: ManagedAgentHookInstallOptions ): Promise { const [agent, install] = entry try { - return await install({ userInitiated }) + return await install(options) } catch (error) { console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error) try { @@ -200,7 +202,16 @@ export async function installManagedAgentHooks( ) continue } - results.push(await runInstaller(entry, options.onInstallError, options.userInitiated)) + const cliVersion = + agent === 'claude' && presence.executablePath + ? await probeClaudeCliVersion(presence.executablePath) + : null + results.push( + await runInstaller(entry, options.onInstallError, { + ...(options.userInitiated !== undefined ? { userInitiated: options.userInitiated } : {}), + ...(cliVersion ? { cliVersion } : {}) + }) + ) } return results } diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 5c462f1794a..49fdcadda42 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -18,7 +18,7 @@ import { openClaudeHookService } from '../openclaude/hook-service' // Why (#16441): Codex's installer awaits a codex app-server trust-grant session // instead of blocking the main thread on spawnSync. Widening the tuple keeps the // other thirteen agent services synchronous — the shared loop already awaits. -export type ManagedAgentHookInstallOptions = { userInitiated?: boolean } +export type ManagedAgentHookInstallOptions = { userInitiated?: boolean; cliVersion?: string } export type ManagedAgentHookInstaller = readonly [ HookInstallAgent, ( @@ -37,7 +37,7 @@ export type ManagedAgentHookAsyncRemover = readonly [ export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ - ['claude', () => claudeHookService.install()], + ['claude', (options) => claudeHookService.install({ claudeVersion: options?.cliVersion })], ['openclaude', () => openClaudeHookService.install()], ['codex', () => codexHookService.install()], ['gemini', () => geminiHookService.install()], diff --git a/src/main/agent-hooks/managed-hook-detection-commands.test.ts b/src/main/agent-hooks/managed-hook-detection-commands.test.ts index abcb24d3969..15f4c041860 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.test.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.test.ts @@ -21,4 +21,13 @@ describe('managed hook detection commands', () => { it('maps detected TUI ids back to managed hook targets', () => { expect(detectedManagedHookAgents(['codex', 'opencode', 'droid'])).toEqual(['codex', 'droid']) }) + + it('requests a version only for Claude capability detection', () => { + const commands = buildManagedHookDetectionCommands(null, 'linux') + + expect(commands.find((command) => command.id === 'claude')).toMatchObject({ + reportVersion: true + }) + expect(commands.find((command) => command.id === 'codex')?.reportVersion).toBeUndefined() + }) }) diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index 2200d183c2e..b5183af7ec0 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -7,6 +7,7 @@ import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targ import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection' import type { GlobalSettings } from '../../shared/global-settings-types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' +import { parseClaudeCliVersion } from '../claude/claude-session-end-hook-capability' export type ManagedHookDetectionSettings = Partial< Pick @@ -26,7 +27,11 @@ export function buildManagedHookDetectionCommands( if (override && isSafeOverrideExecutableToken(override)) { commands.add(override) } - return [...commands].map((cmd) => ({ id: target.tuiAgent, cmd })) + return [...commands].map((cmd) => ({ + id: target.tuiAgent, + cmd, + ...(target.agent === 'claude' ? { reportVersion: true as const } : {}) + })) } ) } @@ -40,3 +45,22 @@ export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] { (target) => target.agent ) } + +export function readManagedHookDetectionResult(value: unknown): { + agents: AgentHookTarget[] + claudeVersion: string | null +} { + if (value === null || typeof value !== 'object') { + return { agents: [], claudeVersion: null } + } + const agents = detectedManagedHookAgents(Reflect.get(value, 'agents')) + const versions = Reflect.get(value, 'versions') + const rawClaudeVersion = + versions !== null && typeof versions === 'object' ? Reflect.get(versions, 'claude') : null + return { + agents, + claudeVersion: parseClaudeCliVersion( + typeof rawClaudeVersion === 'string' ? rawClaudeVersion : null + ) + } +} diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index e995467ee7d..99a2c68be89 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -77,6 +77,7 @@ export async function installManagedHooks(options?: { signal?: AbortSignal hostKeyFingerprint?: string agents?: readonly AgentHookTarget[] + claudeVersion?: string }): Promise { options?.signal?.throwIfAborted() // Why: empty/omitted allowlist fails closed before any home/host probes. @@ -101,7 +102,8 @@ export async function installManagedHooks(options?: { { grokHomeDir, signal: options?.signal, - agents + agents, + ...(options?.claudeVersion ? { claudeVersion: options.claudeVersion } : {}) } ) return { diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index bf97436f8f8..a335e8ccc77 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -22,6 +22,8 @@ export type RemoteManagedHookInstallOptions = { deferTrustUntilConfigToml?: boolean /** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */ grokHomeDir?: string + /** Version reported by Claude on this execution host. */ + claudeVersion?: string /** Stops before starting the next installer when the owning relay request * is cancelled. Individual filesystem mutations remain atomic. */ signal?: AbortSignal @@ -40,7 +42,13 @@ type RemoteManagedHookInstaller = readonly [ ] const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ - ['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)], + [ + 'claude', + (sftp, remoteHome, options) => + claudeHookService.installRemote(sftp, remoteHome, { + claudeVersion: options?.claudeVersion + }) + ], ['openclaude', (sftp, remoteHome) => openClaudeHookService.installRemote(sftp, remoteHome)], [ 'codex', diff --git a/src/main/agent-hooks/wsl-hook-fs-adapter.ts b/src/main/agent-hooks/wsl-hook-fs-adapter.ts index ce2e9dd75f7..71699f57d28 100644 --- a/src/main/agent-hooks/wsl-hook-fs-adapter.ts +++ b/src/main/agent-hooks/wsl-hook-fs-adapter.ts @@ -8,7 +8,7 @@ import type { SFTPWrapper } from 'ssh2' import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import { buildManagedHookDetectionCommands, - detectedManagedHookAgents, + readManagedHookDetectionResult, type ManagedHookDetectionSettings } from './managed-hook-detection-commands' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' @@ -30,11 +30,15 @@ export async function installWslGuestHooks(options: { const { mux, guestHome, codexHomePath, distro, installHooks, settings, warn, installCodex } = options let agents + let claudeVersion: string | null = null try { - const detected = (await mux.request('preflight.detectAgents', { - commands: buildManagedHookDetectionCommands(settings, 'linux') - })) as { agents?: unknown } - agents = detectedManagedHookAgents(detected?.agents) + const detected = readManagedHookDetectionResult( + await mux.request('preflight.detectAgents', { + commands: buildManagedHookDetectionCommands(settings, 'linux') + }) + ) + agents = detected.agents + claudeVersion = detected.claudeVersion } catch (error) { warn( `[agent-hooks] WSL agent detection for '${distro}' failed: ${ @@ -64,7 +68,8 @@ export async function installWslGuestHooks(options: { // runtime-host writer above; the relay adapter owns all other agents. const remoteAgents = agents.filter((agent) => agent !== 'codex') const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, { - agents: remoteAgents + agents: remoteAgents, + ...(claudeVersion ? { claudeVersion } : {}) }) const failed = results.filter((r) => r.state === 'error').length if (failed > 0) { diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 9d92f5d131a..4f4607f423d 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -180,9 +180,13 @@ describe('WslHookRelayManager', () => { } function guestTransport( - options: { registerInstallPlugins?: boolean; detectedAgents?: string[] } = {} + options: { + registerInstallPlugins?: boolean + detectedAgents?: string[] + claudeVersion?: string + } = {} ): MultiplexerTransport { - const { registerInstallPlugins = true, detectedAgents = ['codex'] } = options + const { registerInstallPlugins = true, detectedAgents = ['codex'], claudeVersion } = options const harness = createGuestHarness() harnesses.push(harness) registerWslHookFsHandlers(harness.guestDispatcher, home) @@ -190,7 +194,8 @@ describe('WslHookRelayManager', () => { replayed: 0 })) harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({ - agents: detectedAgents + agents: detectedAgents, + ...(claudeVersion ? { versions: { claude: claudeVersion } } : {}) })) // A guest bundle predating the plugin overlay omits this handler (-32601). if (registerInstallPlugins) { @@ -281,6 +286,22 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) + it('forwards the WSL guest Claude version to the shared remote installer', async () => { + const waitForSentinel = vi.fn(async () => + guestTransport({ detectedAgents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' }) + ) + const { manager, deps } = createManager({ waitForSentinel }) + + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) + + expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, { + agents: ['claude'], + claudeVersion: '2.1.261' + }) + manager.disposeAll() + }) + it('reinstalls into a newly resolved runtime home without restarting the relay', async () => { const { manager, deps } = createManager({}) manager.ensureForDistro('Ubuntu', codexHome) diff --git a/src/main/claude/claude-session-end-hook-capability.test.ts b/src/main/claude/claude-session-end-hook-capability.test.ts new file mode 100644 index 00000000000..755cdcbe498 --- /dev/null +++ b/src/main/claude/claude-session-end-hook-capability.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { + CLAUDE_SESSION_END_CAPABILITY_FLOOR, + claudeVersionSupportsSessionEnd, + parseClaudeCliVersion +} from './claude-session-end-hook-capability' + +describe('Claude SessionEnd hook version capability', () => { + it('records 2.1.261 as the measured floor', () => { + expect(CLAUDE_SESSION_END_CAPABILITY_FLOOR).toBe('2.1.261') + }) + + it('extracts Claude Code version output', () => { + expect(parseClaudeCliVersion('2.1.261 (Claude Code)')).toBe('2.1.261') + }) + + it.each([ + ['2.1.260', false], + ['2.1.261', true], + ['2.2.0', true], + ['unknown', false], + [undefined, false] + ])('classifies %s as SessionEnd-capable: %s', (version, expected) => { + expect(claudeVersionSupportsSessionEnd(version)).toBe(expected) + }) +}) diff --git a/src/main/claude/claude-session-end-hook-capability.ts b/src/main/claude/claude-session-end-hook-capability.ts new file mode 100644 index 00000000000..1587c154e7b --- /dev/null +++ b/src/main/claude/claude-session-end-hook-capability.ts @@ -0,0 +1,41 @@ +import { hasReachedAppVersion, isValidAppVersion } from '../../shared/app-version' +import { runProcess } from '../../shared/child-process/run-process' +import path from 'node:path' + +// 2.1.261 is the only version measured, not an established minimum. +export const CLAUDE_SESSION_END_CAPABILITY_FLOOR = '2.1.261' + +export function parseClaudeCliVersion(output: string | null | undefined): string | null { + const version = output?.match(/\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/)?.[0] + return version && isValidAppVersion(version) ? version : null +} + +export function claudeVersionSupportsSessionEnd(version: string | null | undefined): boolean { + const parsed = parseClaudeCliVersion(version) + return parsed !== null && hasReachedAppVersion(parsed, CLAUDE_SESSION_END_CAPABILITY_FLOOR) +} + +export async function probeClaudeCliVersion(executablePath: string): Promise { + try { + const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(executablePath) + const inheritedPath = process.env[pathKey] + const result = await runProcess({ + program: executablePath, + args: ['--version'], + // Why: version-manager launchers often use `#!/usr/bin/env node`; the resolved CLI's sibling + // runtime must remain reachable even when Electron started with a thinner PATH. + env: { + ...process.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.code === 0 ? parseClaudeCliVersion(`${result.stdout}\n${result.stderr}`) : null + } catch { + return null + } +} diff --git a/src/main/claude/claude-session-end-install.test.ts b/src/main/claude/claude-session-end-install.test.ts new file mode 100644 index 00000000000..46f374ca7ae --- /dev/null +++ b/src/main/claude/claude-session-end-install.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { applyManagedHooks } from './hook-settings' + +const SCRIPT_FILE_NAME = 'claude-hook.sh' +const MANAGED_COMMAND = '/home/dev/.orca/agent-hooks/claude-hook.sh' +const managedHook = { type: 'command' as const, command: MANAGED_COMMAND } + +describe('Claude SessionEnd managed hook capability', () => { + it('installs SessionEnd beside SessionStart for the measured capable version', () => { + const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, { + claudeVersion: '2.1.261 (Claude Code)' + }) + + expect(written.hooks?.SessionEnd?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND) + expect(written.hooks?.SessionStart?.[0]?.hooks?.[0]?.command).toBe(MANAGED_COMMAND) + }) + + it.each(['2.1.260', 'unknown', undefined])( + 'retains the legacy event set for an incapable or unverified host (%s)', + (claudeVersion) => { + const written = applyManagedHooks({ hooks: {} }, managedHook, SCRIPT_FILE_NAME, { + claudeVersion + }) + + expect(written.hooks?.SessionEnd).toBeUndefined() + expect(written.hooks?.SessionStart).toBeDefined() + } + ) + + it('removes only Orca SessionEnd during a capability downgrade', () => { + const capable = applyManagedHooks( + { + hooks: { + SessionEnd: [{ hooks: [{ type: 'command', command: 'echo user-session-end' }] }] + } + }, + managedHook, + SCRIPT_FILE_NAME, + { claudeVersion: '2.1.261' } + ) + const downgraded = applyManagedHooks(capable, managedHook, SCRIPT_FILE_NAME, { + claudeVersion: '2.1.260' + }) + + expect(downgraded.hooks?.SessionEnd).toEqual([ + { hooks: [{ type: 'command', command: 'echo user-session-end' }] } + ]) + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 571531aa2d9..73e7add40dc 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -47,6 +47,10 @@ type ClaudeHookServiceOptions = { settings: ClaudeCompatibleHookSettings } +type ClaudeHookInstallOptions = { + claudeVersion?: string +} + const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { agent: 'claude', displayName: 'Claude', @@ -122,7 +126,7 @@ export class ClaudeHookService { ) } - install(): AgentHookInstallStatus { + install(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) @@ -140,7 +144,8 @@ export class ClaudeHookService { let nextConfig = applyManagedHooks( config, hook, - getManagedScriptFileName(this.options.settings) + getManagedScriptFileName(this.options.settings), + this.options.agent === 'claude' ? options : undefined ) writeManagedScript( scriptPath, @@ -182,7 +187,11 @@ export class ClaudeHookService { } // Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred). - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + async installRemote( + sftp: SFTPWrapper, + remoteHome: string, + options: ClaudeHookInstallOptions = {} + ): Promise { // Why: remote Windows is unsupported; local process.platform cannot identify the remote OS. const remoteConfigPath = getRemoteConfigPath(remoteHome, this.options.settings) const remoteScriptFileName = getPosixManagedScriptFileName(this.options.settings) @@ -202,7 +211,12 @@ export class ClaudeHookService { // Why: settings resolve HOME at runtime while SFTP still targets the discovered remote home. const hook = buildManagedCommandHook(getRemoteManagedCommand(remoteScriptPath)) - const nextConfig = applyManagedHooks(config, hook, remoteScriptFileName) + const nextConfig = applyManagedHooks( + config, + hook, + remoteScriptFileName, + this.options.agent === 'claude' ? options : undefined + ) // Why: write scripts before settings to avoid settings pointing to missing scripts. // Why: SSH scripts always use POSIX .sh paths, regardless of the local OS. diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 047fcbb26b6..92ffa9606a2 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -16,6 +16,7 @@ import { import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command' import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command' import { isGitBashAvailable } from '../git-bash' +import { claudeVersionSupportsSessionEnd } from './claude-session-end-hook-capability' export type ClaudeCompatibleHookSettings = { configDirName: '.claude' | '.openclaude' @@ -101,6 +102,15 @@ export const CLAUDE_EVENTS = [ } ] as const +const CLAUDE_SESSION_END_EVENT = { + eventName: 'SessionEnd', + definition: { hooks: [{ type: 'command', command: '' }] } +} as const + +export type ApplyManagedClaudeHooksOptions = { + claudeVersion?: string +} + export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { return join(homedir(), settings.configDirName, 'settings.json') } @@ -212,12 +222,15 @@ export function getRemoteManagedCommand(scriptPath: string): string { export function applyManagedHooks( config: HooksConfig, hook: HookCommandConfig, - scriptFileName = getManagedScriptFileName() + scriptFileName = getManagedScriptFileName(), + options: ApplyManagedClaudeHooksOptions = {} ): HooksConfig { const nextHooks = { ...config.hooks } const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const sessionEndCapable = claudeVersionSupportsSessionEnd(options.claudeVersion) + const events = sessionEndCapable ? [...CLAUDE_EVENTS, CLAUDE_SESSION_END_EVENT] : CLAUDE_EVENTS - for (const event of CLAUDE_EVENTS) { + for (const event of events) { const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] const cleaned = removeManagedCommands(current, isManagedCommand) const definition: HookDefinition = { @@ -227,6 +240,16 @@ export function applyManagedHooks( nextHooks[event.eventName] = [...cleaned, definition] } + if (!sessionEndCapable) { + const current = Array.isArray(nextHooks.SessionEnd) ? nextHooks.SessionEnd : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks.SessionEnd + } else { + nextHooks.SessionEnd = cleaned + } + } + return { ...config, hooks: nextHooks } } diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 93723965f92..36d5adbf021 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -128,4 +128,34 @@ describe('SshRelaySession managed hooks', () => { muxRequestMock.mock.invocationCallOrder[managedIndex] ) }) + + it('forwards the execution-host Claude version to the remote installer', async () => { + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'preflight.detectAgents') { + return { + agents: ['claude'], + versions: { claude: '2.1.261 (Claude Code)' } + } + } + return method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD + ? { installers: 1, errors: 0 } + : { ok: true } + }) + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: establish only reads these mocked connection members in this harness. + const connection = { + sftp: vi.fn(), + getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(connection) + await vi.waitFor(() => + expect(muxRequestMock).toHaveBeenCalledWith(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { + hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + agents: ['claude'], + claudeVersion: '2.1.261' + }) + ) + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index de865760859..4c3a801f097 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -26,7 +26,7 @@ import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { buildManagedHookDetectionCommands, - detectedManagedHookAgents + readManagedHookDetectionResult } from '../agent-hooks/managed-hook-detection-commands' import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, @@ -1378,17 +1378,20 @@ export class SshRelaySession { try { const store = this.store as { getSettings?: Store['getSettings'] } - const detected = (await mux.request('preflight.detectAgents', { - commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux') - })) as { agents?: unknown } - const agents = detectedManagedHookAgents(detected?.agents) + const detected = readManagedHookDetectionResult( + await mux.request('preflight.detectAgents', { + commands: buildManagedHookDetectionCommands(store.getSettings?.() ?? null, 'linux') + }) + ) + const agents = detected.agents if (agents.length === 0 || (shouldContinue && !shouldContinue())) { return } const hostKeyFingerprint = this.requireReadyConnection().getHostKeyFingerprint?.() const params = { ...(hostKeyFingerprint ? { hostKeyFingerprint } : {}), - agents + agents, + ...(detected.claudeVersion ? { claudeVersion: detected.claudeVersion } : {}) } const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { errors?: unknown diff --git a/src/relay/managed-hook-installer.test.ts b/src/relay/managed-hook-installer.test.ts index f33086e871f..aba8f51490d 100644 --- a/src/relay/managed-hook-installer.test.ts +++ b/src/relay/managed-hook-installer.test.ts @@ -94,4 +94,22 @@ describe('registerManagedHookInstaller', () => { 'invalid_managed_hook_agents' ) }) + + it('forwards only a parseable Claude execution-host version', async () => { + const installManagedHooks = vi.fn().mockResolvedValue({ installers: 1, errors: 0 }) + const handler = captureHandler(() => ({ installManagedHooks })) + + await handler({ agents: ['claude'], claudeVersion: '2.1.261 (Claude Code)' }, context()) + await handler({ agents: ['claude'], claudeVersion: 'unknown' }, context()) + + expect(installManagedHooks).toHaveBeenNthCalledWith(1, { + signal: undefined, + agents: ['claude'], + claudeVersion: '2.1.261' + }) + expect(installManagedHooks).toHaveBeenNthCalledWith(2, { + signal: undefined, + agents: ['claude'] + }) + }) }) diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts index 4bb67da2460..bdd65a789f6 100644 --- a/src/relay/managed-hook-installer.ts +++ b/src/relay/managed-hook-installer.ts @@ -6,6 +6,7 @@ import { import type { RelayDispatcher, RequestContext } from './dispatcher' import type { AgentHookTarget } from '../shared/agent-hook-types' import { isManagedAgentHookTarget } from '../shared/managed-agent-hook-targets' +import { parseClaudeCliVersion } from '../main/claude/claude-session-end-hook-capability' export type ManagedHookInstallSummary = { installers: number @@ -17,6 +18,7 @@ export type ManagedHookRuntime = { signal?: AbortSignal hostKeyFingerprint?: string agents?: readonly AgentHookTarget[] + claudeVersion?: string }) => Promise } @@ -41,6 +43,12 @@ function readAgents(params: unknown): AgentHookTarget[] { return [...new Set(raw)] } +function readClaudeVersion(params: unknown): string | undefined { + const raw = + params !== null && typeof params === 'object' ? Reflect.get(params, 'claudeVersion') : null + return parseClaudeCliVersion(typeof raw === 'string' ? raw : null) ?? undefined +} + let managedHookRuntime: ManagedHookRuntime | null = null function loadManagedHookRuntime(): ManagedHookRuntime { @@ -62,10 +70,12 @@ export function registerManagedHookInstaller( context.signal?.throwIfAborted() const hostKeyFingerprint = readHostKeyFingerprint(params) const agents = readAgents(params) + const claudeVersion = readClaudeVersion(params) return await loadRuntime().installManagedHooks({ signal: context.signal, ...(hostKeyFingerprint ? { hostKeyFingerprint } : {}), - agents + agents, + ...(claudeVersion ? { claudeVersion } : {}) }) } ) diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 06886180624..211c85bceb3 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' -const { execFileAsyncMock } = vi.hoisted(() => ({ - execFileAsyncMock: vi.fn() +const { execFileAsyncMock, runProcessMock } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + runProcessMock: vi.fn() })) const { @@ -30,6 +31,7 @@ vi.mock('../main/wsl', () => ({ listWslDistrosAsync: listWslDistrosAsyncMock })) vi.mock('../main/git-bash', () => ({ isGitBashAvailable: isGitBashAvailableMock })) +vi.mock('../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) import { buildCommandLookupSpec, @@ -65,6 +67,7 @@ function fishLookupArgs(command: string): string[] { beforeEach(() => { execFileAsyncMock.mockReset() + runProcessMock.mockReset() isPwshAvailableAsyncMock.mockReset() isWslAvailableAsyncMock.mockReset() listWslDistrosAsyncMock.mockReset() @@ -237,6 +240,43 @@ describe('hasAbsoluteCommandPath', () => { }) describe('PreflightHandler', () => { + it('reports a requested version from the resolved execution-host binary', async () => { + execFileAsyncMock.mockResolvedValue({ + stdout: '__ORCA_AGENT_PATH__/home/dev/.local/bin/claude\n' + }) + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '2.1.261 (Claude Code)\n', + stderr: '', + timedOut: false + }) + const requestHandlers = new Map) => Promise>() + const dispatcher = { + onRequest: vi.fn( + (method: string, handler: (params: Record) => Promise) => { + requestHandlers.set(method, handler) + } + ) + } + new PreflightHandler(dispatcher as never) + + await expect( + requestHandlers.get('preflight.detectAgents')!({ + commands: [{ id: 'claude', cmd: 'claude', reportVersion: true }] + }) + ).resolves.toEqual({ + agents: ['claude'], + versions: { claude: '2.1.261 (Claude Code)' } + }) + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/home/dev/.local/bin/claude', + args: ['--version'] + }) + ) + }) + it('honors required commands when reporting detected agents', async () => { execFileAsyncMock.mockImplementation(async (_file, args) => { const script = String(args[1]) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a703b26847e..b84166e76ba 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -8,6 +8,7 @@ import { isPwshAvailableAsync } from '../main/pwsh' import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' +import { runProcess } from '../shared/child-process/run-process' const execFileAsync = promisify(execFile) @@ -28,6 +29,7 @@ type AgentDetectionRuntime = NodeJS.Platform | 'wsl' type AgentDetectionCommand = { id: string cmd: string + reportVersion?: true requiredCommands?: readonly string[] unsupportedRuntimes?: readonly AgentDetectionRuntime[] } @@ -54,7 +56,10 @@ export class PreflightHandler { // Why: the client sends the command list rather than importing TUI_AGENT_CONFIG // on the relay side. This keeps the relay bundle minimal and makes the protocol // self-describing — the relay doesn't need to know the agent catalog. - private async detectAgents(params: Record): Promise<{ agents: string[] }> { + private async detectAgents(params: Record): Promise<{ + agents: string[] + versions?: Record + }> { const commands = params.commands as AgentDetectionCommand[] if (!Array.isArray(commands)) { return { agents: [] } @@ -70,26 +75,40 @@ export class PreflightHandler { const results = await Promise.all( probeCommands.map(async (cmd) => ({ cmd, - installed: await this.isCommandOnPath(cmd) + executablePath: await resolveCommandPathForRelay(cmd) })) ) const foundCommands = new Set( - results.filter((result) => result.installed).map(({ cmd }) => cmd) + results.filter((result) => result.executablePath !== null).map(({ cmd }) => cmd) ) + const detectedCommands = commands.filter( + (command) => + !isDetectionUnsupportedInRuntime(command, process.platform) && + foundCommands.has(command.cmd) && + (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) + ) + const versions: Record = {} + for (const command of detectedCommands) { + if ( + command.id !== 'claude' || + command.reportVersion !== true || + versions.claude !== undefined + ) { + continue + } + const executablePath = results.find((result) => result.cmd === command.cmd)?.executablePath + if (!executablePath) { + continue + } + const version = await probeCommandVersion(executablePath) + if (version) { + versions[command.id] = version + } + } return { - agents: [ - ...new Set( - commands - .filter( - (command) => - !isDetectionUnsupportedInRuntime(command, process.platform) && - foundCommands.has(command.cmd) && - (command.requiredCommands ?? []).every((required) => foundCommands.has(required)) - ) - .map(({ id }) => id) - ) - ] + agents: [...new Set(detectedCommands.map(({ id }) => id))], + ...(Object.keys(versions).length > 0 ? { versions } : {}) } } @@ -119,8 +138,33 @@ export class PreflightHandler { // startup files sourced. Ask the user's configured shell so agent dirs added // by zsh/bash/fish startup hooks match the remote terminal experience. // Windows has no POSIX shell on native OpenSSH hosts, so use where.exe there. - private async isCommandOnPath(command: string): Promise { - return isCommandOnPathForRelay(command) +} + +async function probeCommandVersion(executablePath: string): Promise { + try { + const env = buildRelayCommandEnv(process.env, process.platform) + const pathKey = process.platform === 'win32' && env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(executablePath) + const inheritedPath = env[pathKey] + const result = await runProcess({ + program: executablePath, + args: ['--version'], + env: { + ...env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + if (result.code !== 0) { + return null + } + const output = `${result.stdout}\n${result.stderr}`.trim() + return output.length > 0 ? output : null + } catch { + return null } } @@ -172,6 +216,13 @@ export async function isCommandOnPathForRelay( command: string, options: RelayCommandLookupOptions = {} ): Promise { + return (await resolveCommandPathForRelay(command, options)) !== null +} + +export async function resolveCommandPathForRelay( + command: string, + options: RelayCommandLookupOptions = {} +): Promise { const platform = options.platform ?? process.platform const env = options.env ?? process.env const specs = buildCommandLookupSpecs(command, platform, env, options.accountLoginShell) @@ -184,31 +235,39 @@ export async function isCommandOnPathForRelay( timeout: 5000, ...(spec.windowsHide ? { windowsHide: true } : {}) }) - if (hasAbsoluteCommandPath(stdout, platform)) { - return true + const resolvedPath = getAbsoluteCommandPath(stdout, platform) + if (resolvedPath) { + return resolvedPath } } catch { // Try the inherited-PATH fallback before reporting the agent missing. } } - return false + return null } export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { + return getAbsoluteCommandPath(output, platform) !== null +} + +function getAbsoluteCommandPath(output: string, platform: NodeJS.Platform): string | null { const pathOps = platform === 'win32' ? win32 : path - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .some((line) => { - const resolvedPath = - platform === 'win32' - ? line - : line.startsWith(AGENT_PATH_PREFIX) - ? line.slice(AGENT_PATH_PREFIX.length) - : '' - return pathOps.isAbsolute(resolvedPath) - }) + return ( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .map((line) => { + const resolvedPath = + platform === 'win32' + ? line + : line.startsWith(AGENT_PATH_PREFIX) + ? line.slice(AGENT_PATH_PREFIX.length) + : '' + return pathOps.isAbsolute(resolvedPath) ? resolvedPath : null + }) + .find((resolvedPath): resolvedPath is string => resolvedPath !== null) ?? null + ) } function buildPosixCommandLookupSpec(command: string, shell: string): CommandLookupSpec { diff --git a/src/shared/agent-hook-listener/providers/claude-roster-state.ts b/src/shared/agent-hook-listener/providers/claude-roster-state.ts index 49a032edf9e..c21166b5ce9 100644 --- a/src/shared/agent-hook-listener/providers/claude-roster-state.ts +++ b/src/shared/agent-hook-listener/providers/claude-roster-state.ts @@ -26,8 +26,8 @@ const CLAUDE_SESSION_OWNER_EVENTS: ReadonlySet = new Set([ ]) /** A pane whose `session_id` changed is running a different conversation, so claims the previous one - * owned are void — the hook-independent backstop for /clear, relaunch and resume, which emit no - * terminating hook (SessionEnd covers about a third of exit paths). + * owned are void — the hook-independent backstop for /clear, relaunch and resume. Modern Claude + * emits SessionEnd on /clear, but Orca previously did not install it and older binaries emit none. * * Voids only what the replaced session provably owned. Deliberately NOT voided: * - `claudeRunningNonAgentTaskPaneKeys`: a background shell is an OS process that survives /clear, diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index da58833757c..dadfa590a4e 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -217,6 +217,8 @@ export type AgentHookInstallManagedHooksParams = { hostKeyFingerprint?: string /** Positively detected and enabled agents allowed to mutate remote config. */ agents: readonly AgentHookTarget[] + /** Execution-host Claude version; absent means retain the legacy hook set. */ + claudeVersion?: string } /** Feature-flag env var. Read once at process start by Orca and the relay. diff --git a/src/shared/tui-agent-detection-commands.ts b/src/shared/tui-agent-detection-commands.ts index 81ee6589649..1d9409a090b 100644 --- a/src/shared/tui-agent-detection-commands.ts +++ b/src/shared/tui-agent-detection-commands.ts @@ -9,6 +9,8 @@ import { export type TuiAgentDetectionCommand = { id: TuiAgent cmd: string + /** Ask an execution host to report this CLI's `--version` output when found. */ + reportVersion?: true requiredCommands?: readonly string[] unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[] }