From d4703bd1ab529bedfcd648bb1841ccc81433e643 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 25 May 2026 10:37:22 -0700 Subject: [PATCH] Restore agent hook opt-out controls (#2778) --- config/electron-builder.config.cjs | 13 +- config/tsconfig.cli.json | 21 +- electron.vite.config.ts | 7 +- src/cli/args.ts | 2 + src/cli/dispatch.ts | 2 + src/cli/handlers/agent-hooks.ts | 163 ++++++++++ src/cli/index.ts | 4 +- src/cli/specs/agent-hooks.ts | 26 ++ src/cli/specs/index.ts | 2 + .../managed-agent-hook-controls.ts | 121 ++++++++ .../remote-hook-service-installers.test.ts | 55 +++- .../remote-managed-hook-installers.ts | 2 +- src/main/claude/hook-service.test.ts | 101 ++++++- src/main/claude/hook-service.ts | 250 +++++++--------- src/main/claude/hook-settings.ts | 124 ++++++++ .../runtime-home-service.test.ts | 4 +- src/main/codex-accounts/service.test.ts | 4 +- src/main/codex/config-toml-trust.ts | 4 +- src/main/codex/hook-service.test.ts | 73 ++++- src/main/codex/hook-service.ts | 283 +++++++++++++++++- src/main/index.ts | 38 +-- src/main/ipc/agent-hooks.ts | 2 +- src/main/ipc/pty.test.ts | 86 +++++- src/main/ipc/pty.ts | 109 +++++-- src/main/ipc/settings.ts | 11 + src/main/runtime/orca-runtime.test.ts | 9 +- src/main/runtime/orca-runtime.ts | 23 +- src/main/runtime/rpc/methods/client-ui.ts | 1 + src/main/ssh/ssh-relay-session.ts | 10 +- .../FloatingTerminalWindowControls.tsx | 4 +- .../onboarding-folder-agent-startup.test.ts | 3 +- .../components/settings/AgentsPane.test.tsx | 54 +++- .../src/components/settings/AgentsPane.tsx | 27 +- .../settings/agent-status-hooks-copy.ts | 20 ++ .../src/components/settings/agents-search.ts | 10 + src/renderer/src/hooks/useComposerState.ts | 14 +- .../launch-agent-background-session.test.ts | 23 +- .../lib/launch-agent-background-session.ts | 9 +- .../src/lib/launch-agent-in-new-tab.ts | 21 +- .../src/lib/launch-work-item-direct.ts | 8 +- .../lib/onboarding-folder-agent-startup.ts | 4 +- .../worktree-activation-created-agent.test.ts | 3 +- src/renderer/src/lib/worktree-activation.ts | 6 +- .../repos-onboarding-folder-startup.test.ts | 3 +- src/shared/claude-settings.ts | 19 ++ src/shared/codex-profile.ts | 5 + src/shared/constants.ts | 1 + src/shared/tui-agent-startup.test.ts | 73 +++++ src/shared/tui-agent-startup.ts | 42 ++- src/shared/types.ts | 3 + 50 files changed, 1627 insertions(+), 275 deletions(-) create mode 100644 src/cli/handlers/agent-hooks.ts create mode 100644 src/cli/specs/agent-hooks.ts create mode 100644 src/main/agent-hooks/managed-agent-hook-controls.ts create mode 100644 src/main/claude/hook-settings.ts create mode 100644 src/renderer/src/components/settings/agent-status-hooks-copy.ts create mode 100644 src/shared/claude-settings.ts create mode 100644 src/shared/codex-profile.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index e541a5212..8fabe3013 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -35,7 +35,7 @@ module.exports = { '!resources/onboarding/feature-wall/**' ], // Why: the CLI entry-point lives in out/cli/ but imports shared modules - // from out/shared/ (e.g. runtime-bootstrap). Both directories must be + // from out/shared/ and local hook mutators from out/main/. These paths must be // unpacked so that Node's require() can resolve the cross-directory imports // when the CLI runs outside the asar archive. // Why: daemon-entry.js is forked as a separate Node.js process and must be @@ -54,6 +54,17 @@ module.exports = { asarUnpack: [ 'out/cli/**', 'out/shared/**', + 'out/main/agent-hooks/**', + 'out/main/antigravity/**', + 'out/main/claude/**', + 'out/main/codex/**', + 'out/main/copilot/**', + 'out/main/cursor/**', + 'out/main/droid/**', + 'out/main/gemini/**', + 'out/main/grok/**', + 'out/main/hermes/**', + 'out/main/win32-utils.js', 'out/main/daemon-entry.js', 'out/main/computer-sidecar.js', 'out/main/chunks/**', diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index dbcdaa729..1f5c5bf0a 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -1,6 +1,25 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["../src/cli/**/*", "../src/shared/**/*", "../src/main/runtime/runtime-metadata.ts"], + "include": [ + "../src/cli/**/*", + "../src/shared/**/*", + "../src/main/agent-hooks/installer-utils.ts", + "../src/main/agent-hooks/installer-utils-remote.ts", + "../src/main/agent-hooks/managed-agent-hook-controls.ts", + "../src/main/antigravity/hook-service.ts", + "../src/main/claude/hook-settings.ts", + "../src/main/claude/hook-service.ts", + "../src/main/codex/config-toml-trust.ts", + "../src/main/codex/hook-service.ts", + "../src/main/copilot/hook-service.ts", + "../src/main/cursor/hook-service.ts", + "../src/main/droid/hook-service.ts", + "../src/main/gemini/hook-service.ts", + "../src/main/grok/hook-service.ts", + "../src/main/hermes/hook-service.ts", + "../src/main/runtime/runtime-metadata.ts", + "../src/main/win32-utils.ts" + ], "compilerOptions": { "composite": true, "module": "CommonJS", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index da5357bc7..c715965c4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -48,7 +48,12 @@ export default defineConfig({ index: resolve('src/main/index.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), - 'stt-worker': resolve('src/main/speech/stt-worker.ts') + 'stt-worker': resolve('src/main/speech/stt-worker.ts'), + // Why: electron-vite cleans out/main in dev. The dev CLI imports + // this path for `orca agent hooks ...`, so it must survive rebuilds. + 'agent-hooks/managed-agent-hook-controls': resolve( + 'src/main/agent-hooks/managed-agent-hook-controls.ts' + ) } } }, diff --git a/src/cli/args.ts b/src/cli/args.ts index 5b9933063..14395970b 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -98,8 +98,10 @@ export function isCommandGroup(commandPath: string[]): boolean { 'storage', 'orchestration', 'computer', + 'agent', 'environment' ].includes(commandPath[0])) || + (commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') || (commandPath.length === 2 && commandPath[0] === 'storage' && ['local', 'session'].includes(commandPath[1])) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 976987645..1b7b19863 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -16,6 +16,7 @@ import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' import { ENVIRONMENT_HANDLERS } from './handlers/environment' +import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks' export type HandlerContext = { flags: Map @@ -44,6 +45,7 @@ function buildHandlers(): Map { BROWSER_STORAGE_HANDLERS, ORCHESTRATION_HANDLERS, COMPUTER_HANDLERS, + AGENT_HOOK_HANDLERS, ENVIRONMENT_HANDLERS ] for (const group of groups) { diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts new file mode 100644 index 000000000..fdd6b3114 --- /dev/null +++ b/src/cli/handlers/agent-hooks.ts @@ -0,0 +1,163 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' +import { randomUUID } from 'crypto' +import type { CommandHandler } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError, type RuntimeClient, type RuntimeRpcSuccess } from '../runtime-client' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { getDefaultPersistedState } from '../../shared/constants' +import type { PersistedState } from '../../shared/types' +import { + applyAgentStatusHooksEnabled, + getManagedAgentHookStatuses +} from '../../main/agent-hooks/managed-agent-hook-controls' +import { getDefaultUserDataPath } from '../runtime-client' + +type AgentHookCommandResult = { + enabled: boolean + settingsPath: string + appliedBy: 'runtime' | 'offline' + statuses: AgentHookInstallStatus[] +} + +function getDataPath(): string { + return join(getDefaultUserDataPath(), 'orca-data.json') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function readPersistedState(dataPath: string): PersistedState { + if (!existsSync(dataPath)) { + return getDefaultPersistedState(homedir()) + } + try { + const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) + if (!isRecord(parsed)) { + throw new Error('file does not contain a JSON object') + } + return parsed as PersistedState + } catch (error) { + throw new RuntimeClientError( + 'runtime_error', + `Could not read ${dataPath}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function writePersistedState(dataPath: string, state: PersistedState): void { + mkdirSync(dirname(dataPath), { recursive: true }) + const tmpPath = join(dirname(dataPath), `.${Date.now()}-${randomUUID()}.tmp`) + let renamed = false + try { + writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8') + renameSync(tmpPath, dataPath) + renamed = true + } finally { + if (!renamed && existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort + } + } + } +} + +function readEnabledFromDisk(): boolean { + const state = readPersistedState(getDataPath()) + return state.settings?.agentStatusHooksEnabled !== false +} + +function updateEnabledOnDisk(enabled: boolean): string { + const dataPath = getDataPath() + const state = readPersistedState(dataPath) + state.settings = { + ...getDefaultPersistedState(homedir()).settings, + ...state.settings, + agentStatusHooksEnabled: enabled + } + writePersistedState(dataPath, state) + return dataPath +} + +async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise { + try { + const status = await client.getCliStatus() + if (!status.result.runtime.reachable) { + return false + } + await client.call( + 'settings.update', + { agentStatusHooksEnabled: enabled }, + { timeoutMs: 10_000 } + ) + return true + } catch { + return false + } +} + +function localSuccess(result: TResult): RuntimeRpcSuccess { + return { + id: 'local', + ok: true, + result, + _meta: { + runtimeId: 'local' + } + } +} + +function formatAgentHookCommandResult(result: AgentHookCommandResult): string { + const statusSummary = result.statuses + .map((status) => `${status.agent}: ${status.state}`) + .join('\n') + return [ + `agentStatusHooksEnabled: ${result.enabled}`, + `appliedBy: ${result.appliedBy}`, + `settingsPath: ${result.settingsPath}`, + statusSummary + ] + .filter(Boolean) + .join('\n') +} + +async function setAgentHooksEnabled( + client: RuntimeClient, + enabled: boolean +): Promise { + const updatedRuntime = await updateRunningRuntime(client, enabled) + const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled) + const statuses = updatedRuntime + ? getManagedAgentHookStatuses() + : applyAgentStatusHooksEnabled(enabled) + return { + enabled, + settingsPath, + appliedBy: updatedRuntime ? 'runtime' : 'offline', + statuses + } +} + +export const AGENT_HOOK_HANDLERS: Record = { + 'agent hooks status': async ({ json }) => { + const result: AgentHookCommandResult = { + enabled: readEnabledFromDisk(), + settingsPath: getDataPath(), + appliedBy: 'offline', + statuses: getManagedAgentHookStatuses() + } + printResult(localSuccess(result), json, formatAgentHookCommandResult) + }, + 'agent hooks off': async ({ client, json }) => { + const result = await setAgentHooksEnabled(client, false) + printResult(localSuccess(result), json, formatAgentHookCommandResult) + }, + 'agent hooks on': async ({ client, json }) => { + const result = await setAgentHooksEnabled(client, true) + printResult(localSuccess(result), json, formatAgentHookCommandResult) + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 247b73cea..d8218a070 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -17,7 +17,9 @@ export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' function shouldIgnoreRemoteSelection(commandPath: string[]): boolean { - return commandPath[0] === 'environment' || commandPath[0] === 'serve' + return ( + commandPath[0] === 'environment' || commandPath[0] === 'serve' || commandPath[0] === 'agent' + ) } export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { diff --git a/src/cli/specs/agent-hooks.ts b/src/cli/specs/agent-hooks.ts new file mode 100644 index 000000000..b328189f3 --- /dev/null +++ b/src/cli/specs/agent-hooks.ts @@ -0,0 +1,26 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const AGENT_HOOK_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['agent', 'hooks', 'status'], + summary: 'Show whether Orca-managed agent status hooks are enabled', + usage: 'orca agent hooks status [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca agent hooks status', 'orca agent hooks status --json'] + }, + { + path: ['agent', 'hooks', 'off'], + summary: 'Disable Orca-managed agent status hooks and remove local hook entries', + usage: 'orca agent hooks off [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca agent hooks off'] + }, + { + path: ['agent', 'hooks', 'on'], + summary: 'Enable Orca-managed agent status hooks', + usage: 'orca agent hooks on [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca agent hooks on'] + } +] diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 4695f831a..a32e88d39 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -6,6 +6,7 @@ import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' import { ENVIRONMENT_COMMAND_SPECS } from './environment' +import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, @@ -14,5 +15,6 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, ...COMPUTER_COMMAND_SPECS, + ...AGENT_HOOK_COMMAND_SPECS, ...ENVIRONMENT_COMMAND_SPECS ] diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts new file mode 100644 index 000000000..9bbb54016 --- /dev/null +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -0,0 +1,121 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import type { HookInstallAgent } from '../../shared/telemetry-events' +import type { GlobalSettings } from '../../shared/types' +import { antigravityHookService } from '../antigravity/hook-service' +import { claudeHookService } from '../claude/hook-service' +import { codexHookService } from '../codex/hook-service' +import { copilotHookService } from '../copilot/hook-service' +import { cursorHookService } from '../cursor/hook-service' +import { droidHookService } from '../droid/hook-service' +import { geminiHookService } from '../gemini/hook-service' +import { grokHookService } from '../grok/hook-service' +import { hermesHookService } from '../hermes/hook-service' + +export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void] +type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] +type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] + +export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [ + ['claude', () => claudeHookService.install()], + [ + 'codex', + () => { + // Why: the Orca-specific Codex profile keeps normal external `codex` + // runs from loading Orca hooks; remove legacy global entries after it is ready. + codexHookService.installProfile() + codexHookService.remove() + } + ], + ['gemini', () => geminiHookService.install()], + ['antigravity', () => antigravityHookService.install()], + ['cursor', () => cursorHookService.install()], + ['droid', () => droidHookService.install()], + ['grok', () => grokHookService.install()], + ['copilot', () => copilotHookService.install()], + ['hermes', () => hermesHookService.install()] +] + +const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ + ['claude', () => claudeHookService.remove()], + [ + 'codex', + () => { + const globalStatus = codexHookService.remove() + const profileStatus = codexHookService.removeProfile() + return profileStatus.state === 'error' ? profileStatus : globalStatus + } + ], + ['gemini', () => geminiHookService.remove()], + ['antigravity', () => antigravityHookService.remove()], + ['cursor', () => cursorHookService.remove()], + ['droid', () => droidHookService.remove()], + ['grok', () => grokHookService.remove()], + ['copilot', () => copilotHookService.remove()], + ['hermes', () => hermesHookService.remove()] +] + +const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ + ['claude', () => claudeHookService.getStatus()], + ['codex', () => codexHookService.getProfileStatus()], + ['gemini', () => geminiHookService.getStatus()], + ['antigravity', () => antigravityHookService.getStatus()], + ['cursor', () => cursorHookService.getStatus()], + ['droid', () => droidHookService.getStatus()], + ['grok', () => grokHookService.getStatus()], + ['copilot', () => copilotHookService.getStatus()], + ['hermes', () => hermesHookService.getStatus()] +] + +export function isAgentStatusHooksEnabled( + settings: Pick | null | undefined +): boolean { + return settings?.agentStatusHooksEnabled !== false +} + +export function installManagedAgentHooks(): void { + for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) { + try { + install() + } catch (error) { + console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error) + } + } +} + +function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus { + return { + agent, + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } +} + +export function removeManagedAgentHooks(): AgentHookInstallStatus[] { + return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => { + try { + return remove() + } catch (error) { + return errorStatus(agent, error) + } + }) +} + +export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] { + return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => { + try { + return getStatus() + } catch (error) { + return errorStatus(agent, error) + } + }) +} + +export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] { + if (enabled) { + installManagedAgentHooks() + return getManagedAgentHookStatuses() + } + return removeManagedAgentHooks() +} diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index b4cc662b4..34e502f84 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -24,9 +24,12 @@ type FakeFs = { failRenameTo: Set } -function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } { +function createFakeSftp(initialFiles: Record = {}): { + sftp: SFTPWrapper + fs: FakeFs +} { const fs: FakeFs = { - files: new Map(), + files: new Map(Object.entries(initialFiles)), dirs: new Set(['/']), modes: new Map(), failRenameTo: new Set() @@ -190,6 +193,54 @@ describe('remote hook service installers', () => { expect(toml).toContain('trusted_hash = "sha256:') }) + it('installs remote Codex profile hooks and sweeps legacy global entries', async () => { + const { sftp, fs } = createFakeSftp({ + '/home/dev/.codex/hooks.json': JSON.stringify({ + hooks: { + PreToolUse: [ + { + hooks: [ + { + type: 'command', + command: + 'if [ -x /home/dev/.orca/agent-hooks/codex-hook.sh ]; then /bin/sh /home/dev/.orca/agent-hooks/codex-hook.sh; fi' + } + ] + } + ] + } + }) + }) + + const status = await new CodexHookService().installRemoteProfile(sftp, '/home/dev/') + + expect(status.state).toBe('installed') + expect(status.configPath).toBe('/home/dev/.codex/orca-agent-status.config.toml') + const profile = fs.files.get('/home/dev/.codex/orca-agent-status.config.toml')! + expect(profile).toContain('[[hooks.PermissionRequest]]') + expect(profile).toContain( + '/home/dev/.codex/orca-agent-status.config.toml:permission_request:0:0' + ) + expect(profile).toContain('/home/dev/.orca/agent-hooks/codex-hook.sh') + expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh') + const globalHooks = JSON.parse(fs.files.get('/home/dev/.codex/hooks.json')!) as { + hooks?: Record + } + expect(globalHooks.hooks?.PreToolUse).toBeUndefined() + }) + + it('does not create remote legacy Codex hooks.json when profile install has nothing to sweep', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await new CodexHookService().installRemoteProfile(sftp, '/home/dev/') + + expect(status.state).toBe('installed') + expect(fs.files.get('/home/dev/.codex/orca-agent-status.config.toml')).toContain( + '[[hooks.PermissionRequest]]' + ) + expect(fs.files.has('/home/dev/.codex/hooks.json')).toBe(false) + }) + it('reports Codex trust-write failures without rolling back installed hooks', async () => { const { sftp, fs } = createFakeSftp() fs.failRenameTo.add('/home/dev/.codex/config.toml') diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index a8ef3d910..3baa96f9d 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -15,7 +15,7 @@ type RemoteManagedHookInstaller = readonly [ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)], - ['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)], + ['codex', (sftp, remoteHome) => codexHookService.installRemoteProfile(sftp, remoteHome)], ['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)], ['antigravity', (sftp, remoteHome) => antigravityHookService.installRemote(sftp, remoteHome)], ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 00bfdc5cb..b40f68845 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -3,6 +3,9 @@ // or the script body that lands on the remote box. Local install behavior // is exercised through `installer-utils.test.ts` and the per-CLI status // audit; this file covers ONLY the SFTP-backed path added in commit #8. +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' import { vi, describe, expect, it } from 'vitest' vi.mock('electron', () => ({ @@ -14,6 +17,8 @@ vi.mock('electron', () => ({ import type { SFTPWrapper } from 'ssh2' import { ClaudeHookService } from './hook-service' +const CLAUDE_SETTINGS_FILE = 'claude-agent-status-settings.json' + type FakeFs = { files: Map dirs: Set @@ -98,14 +103,79 @@ function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } { return { sftp, fs } } +describe('ClaudeHookService.install', () => { + it('keeps the scoped settings hook-only and preserves user Bedrock settings', () => { + const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-hooks-')) + vi.stubEnv('HOME', tmpHome) + try { + const legacyPath = join(tmpHome, '.claude', 'settings.json') + mkdirSync(join(tmpHome, '.claude'), { recursive: true }) + writeFileSync( + legacyPath, + JSON.stringify({ + apiKeyHelper: '/opt/company/claude-key-helper', + awsAuthRefresh: '/opt/company/aws-refresh', + awsCredentialExport: '/opt/company/aws-export', + env: { + CLAUDE_CODE_USE_BEDROCK: '1', + AWS_REGION: 'us-west-2' + }, + hooks: { + Stop: [ + { + hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] + }, + { + hooks: [ + { + type: 'command', + command: '/Users/old/.orca/agent-hooks/claude-hook.sh' + } + ] + } + ] + } + }) + ) + + const status = new ClaudeHookService().install() + expect(status.state).toBe('installed') + + const scoped = JSON.parse( + readFileSync(join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SETTINGS_FILE), 'utf-8') + ) + expect(Object.keys(scoped)).toEqual(['hooks']) + + const legacy = JSON.parse(readFileSync(legacyPath, 'utf-8')) + expect(legacy).toMatchObject({ + apiKeyHelper: '/opt/company/claude-key-helper', + awsAuthRefresh: '/opt/company/aws-refresh', + awsCredentialExport: '/opt/company/aws-export', + env: { + CLAUDE_CODE_USE_BEDROCK: '1', + AWS_REGION: 'us-west-2' + } + }) + const legacyCommands = legacy.hooks.Stop.flatMap( + (definition: { hooks: { command: string }[] }) => + definition.hooks.map((hook) => hook.command) + ) + expect(legacyCommands).toEqual(['/usr/local/bin/user-hook']) + } finally { + vi.unstubAllEnvs() + rmSync(tmpHome, { recursive: true, force: true }) + } + }) +}) + describe('ClaudeHookService.installRemote', () => { - it('writes settings.json + managed script under the remote $HOME', async () => { + it('writes scoped settings + managed script under the remote $HOME', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() const status = await svc.installRemote(sftp, '/home/dev') expect(status.state).toBe('installed') - expect(status.configPath).toBe('/home/dev/.claude/settings.json') - const settings = fs.files.get('/home/dev/.claude/settings.json') + expect(status.configPath).toBe('/home/dev/.orca/agent-hooks/claude-agent-status-settings.json') + const settings = fs.files.get('/home/dev/.orca/agent-hooks/claude-agent-status-settings.json') expect(settings).toBeTruthy() const parsed = JSON.parse(settings!) // Why: every load-bearing event must be present and point at the @@ -129,18 +199,20 @@ describe('ClaudeHookService.installRemote', () => { // Managed script body expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toContain('#!/bin/sh') expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755) + expect(fs.files.has('/home/dev/.claude/settings.json')).toBe(false) }) - it('reports parse error when remote settings.json is malformed', async () => { + it('reports parse error when legacy remote settings.json cannot be cleaned', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() fs.files.set('/home/dev/.claude/settings.json', 'not json') const status = await svc.installRemote(sftp, '/home/dev') expect(status.state).toBe('error') - expect(status.detail).toContain('Could not parse') + expect(status.managedHooksPresent).toBe(true) + expect(status.detail).toContain('Scoped Claude hooks installed') }) - it('preserves user-authored hook entries on a fresh install', async () => { + it('preserves user-authored legacy hook entries while sweeping old managed entries', async () => { const svc = new ClaudeHookService() const { sftp, fs } = createFakeSftp() fs.files.set( @@ -150,6 +222,15 @@ describe('ClaudeHookService.installRemote', () => { Stop: [ { hooks: [{ type: 'command', command: '/usr/local/bin/my-user-hook' }] + }, + { + hooks: [ + { + type: 'command', + command: + 'if [ -x /home/dev/.orca/agent-hooks/claude-hook.sh ]; then /bin/sh /home/dev/.orca/agent-hooks/claude-hook.sh; fi' + } + ] } ] } @@ -157,10 +238,14 @@ describe('ClaudeHookService.installRemote', () => { ) await svc.installRemote(sftp, '/home/dev') const parsed = JSON.parse(fs.files.get('/home/dev/.claude/settings.json')!) - // Original user-authored entry survives alongside the new managed entry. + // Original user-authored entry survives, while legacy global Orca entries + // are removed because scoped --settings carries the managed hook now. const stopDefs = parsed.hooks.Stop as { hooks: { command: string }[] }[] const userCmds = stopDefs.flatMap((d) => d.hooks.map((h) => h.command)) expect(userCmds).toContain('/usr/local/bin/my-user-hook') - expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(true) + expect(userCmds.some((c) => c.includes('claude-hook.sh'))).toBe(false) + expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-agent-status-settings.json')).toContain( + 'claude-hook.sh' + ) }) }) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 63148e5a2..5a8bac41c 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -1,72 +1,30 @@ -import { homedir } from 'os' -import { join } from 'path' +import { existsSync, unlinkSync } from 'fs' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV } from '../../shared/claude-settings' import { - createManagedCommandMatcher, buildWindowsAgentHookPostCommand, - getSharedManagedScriptPath, readHooksJson, - removeManagedCommands, - wrapPosixHookCommand, writeHooksJson, - writeManagedScript, - type HookDefinition + writeManagedScript } from '../agent-hooks/installer-utils' import { readHooksJsonRemote, writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' - -const CLAUDE_EVENTS = [ - { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, - { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, - // Why: PreToolUse gives the dashboard a live readout of the in-flight tool - // (name + input preview) before it completes. Without it, a long-running - // Bash/Task step looks like a silent gap between prompt and Stop. - { - eventName: 'PreToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PostToolUseFailure', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - }, - { - eventName: 'PermissionRequest', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } - } -] as const - -function getConfigPath(): string { - return join(homedir(), '.claude', 'settings.json') -} - -function getManagedScriptFileName(): string { - return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' -} - -function getManagedScriptPath(): string { - return getSharedManagedScriptPath(getManagedScriptFileName()) -} - -function getManagedCommand(scriptPath: string): string { - if (process.platform === 'win32') { - // Why: on Windows, Claude Code runs hooks through Git Bash (`/usr/bin/bash`). - // A path with single backslashes (e.g. `C:\Users\…\claude-hook.cmd`) is - // interpreted by bash as a string with escape sequences, so `\U`, `\A`, etc. - // collapse and the launcher fails with `command not found`. Emit forward - // slashes — Windows accepts them in path arguments and bash leaves them - // intact, so the same JSON value works through every shell layer. - return scriptPath.replaceAll('\\', '/') - } - return wrapPosixHookCommand(scriptPath) -} +import { + applyManagedHooks, + CLAUDE_EVENTS, + getLegacyConfigPath, + getManagedCommand, + getManagedScriptPath, + getRemoteLegacyConfigPath, + getRemoteManagedCommand, + getRemoteScopedSettingsPath, + getScopedSettingsPath, + removeManagedHooks +} from './hook-settings' function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { @@ -134,7 +92,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { export class ClaudeHookService { getStatus(): AgentHookInstallStatus { - const configPath = getConfigPath() + const configPath = getScopedSettingsPath() const scriptPath = getManagedScriptPath() const config = readHooksJson(configPath) if (!config) { @@ -143,7 +101,7 @@ export class ClaudeHookService { state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: 'Could not parse Orca Claude settings file' } } @@ -184,8 +142,51 @@ export class ClaudeHookService { } install(): AgentHookInstallStatus { - const configPath = getConfigPath() + const scopedStatus = this.installScopedSettings() + if (scopedStatus.state === 'error') { + return scopedStatus + } + const legacyStatus = this.removeLegacyGlobalHooks() + if (legacyStatus.state === 'error') { + return { + ...legacyStatus, + managedHooksPresent: scopedStatus.managedHooksPresent, + detail: scopedStatus.managedHooksPresent + ? `Scoped Claude hooks installed, but ${legacyStatus.detail}` + : legacyStatus.detail + } + } + return scopedStatus + } + + buildPtyEnv(): Record { + try { + const status = this.installScopedSettings() + if (status.state === 'error') { + console.warn(`[agent-hooks] Failed to prepare Claude scoped settings: ${status.detail}`) + } + } catch (error) { + console.warn('[agent-hooks] Failed to prepare Claude scoped settings:', error) + } + return { + [ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV]: getScopedSettingsPath() + } + } + + private installScopedSettings(): AgentHookInstallStatus { + const configPath = getScopedSettingsPath() const scriptPath = getManagedScriptPath() + const config = readHooksJson(configPath) ?? {} + + const command = getManagedCommand(scriptPath) + const nextConfig = applyManagedHooks(config, command) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, nextConfig) + return this.getStatus() + } + + private removeLegacyGlobalHooks(): AgentHookInstallStatus { + const configPath = getLegacyConfigPath() const config = readHooksJson(configPath) if (!config) { return { @@ -193,46 +194,28 @@ export class ClaudeHookService { state: 'error', configPath, managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' + detail: 'Could not parse Claude settings.json to remove legacy global hooks' } } - - const command = getManagedCommand(scriptPath) - const nextHooks = { ...config.hooks } - - // Why: match by script filename (not exact command string) so a fresh - // install sweeps stale entries left by older builds or a different - // Electron userData path (dev vs. prod). Without this, repeated installs - // accumulate duplicate hook entries pointing at defunct scripts. - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - - for (const event of CLAUDE_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [{ type: 'command', command }] - } - nextHooks[event.eventName] = [...cleaned, definition] + const { config: nextConfig, changed } = removeManagedHooks(config) + if (changed) { + writeHooksJson(configPath, nextConfig) } - - config.hooks = nextHooks - writeManagedScript(scriptPath, getManagedScript()) - writeHooksJson(configPath, config) return this.getStatus() } - // Why: install Orca's managed Claude hooks on the remote box rather than - // the local Mac/Linux machine. Caller passes the user's SFTP handle from - // the SshConnection plus the resolved remote `$HOME` (used to compute - // ~/.claude/settings.json on the target). POSIX-only by design — see + // Why: install Orca's scoped Claude hook settings on the remote box rather + // than the local Mac/Linux machine. Caller passes the user's SFTP handle + // from the SshConnection plus the resolved remote `$HOME` used to compute + // the Orca-owned settings path. POSIX-only by design — see // docs/design/agent-status-over-ssh.md §3 / §6 (Windows-remote deferred). async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { // Why: remote-Windows is out of scope for v1 — we ship POSIX-shaped paths - // (`~/.claude/settings.json`) and a `.sh` managed script body. The remote - // platform is gated by the relay's capability RPC at a higher layer; we - // cannot detect it from `process.platform` here (that's the local box). - const remoteConfigPath = `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` + // and a `.sh` managed script body. The remote platform is gated by the + // relay's capability RPC at a higher layer; we cannot detect it from + // `process.platform` here (that's the local box). + const remoteConfigPath = getRemoteScopedSettingsPath(remoteHome) + const remoteLegacyConfigPath = getRemoteLegacyConfigPath(remoteHome) const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/claude-hook.sh` // Why: SFTP reads/writes fail far more often than local fs (network drops, // EACCES on remote dirs, disk full, channel closed). Wrap the entire @@ -242,33 +225,12 @@ export class ClaudeHookService { // specifically means "file present but unparseable" — keep that branch // distinct so the user sees an actionable message. try { - const config = await readHooksJsonRemote(sftp, remoteConfigPath) - if (!config) { - return { - agent: 'claude', - state: 'error', - configPath: remoteConfigPath, - managedHooksPresent: false, - detail: 'Could not parse remote Claude settings.json' - } - } + const config = (await readHooksJsonRemote(sftp, remoteConfigPath)) ?? {} // Why: the POSIX wrapper is identical regardless of where the script // lands; only the path differs. Reuse the same wrapper helper. - const command = wrapPosixHookCommand(remoteScriptPath) - const nextHooks = { ...config.hooks } - const isManagedCommand = createManagedCommandMatcher('claude-hook.sh') - - for (const event of CLAUDE_EVENTS) { - const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] - const cleaned = removeManagedCommands(current, isManagedCommand) - const definition: HookDefinition = { - ...event.definition, - hooks: [{ type: 'command', command }] - } - nextHooks[event.eventName] = [...cleaned, definition] - } - config.hooks = nextHooks + const command = getRemoteManagedCommand(remoteScriptPath) + const nextConfig = applyManagedHooks(config, command, 'claude-hook.sh') // Why: write the script first, then the settings — settings.json // referencing a missing script body would fire `command not found` on @@ -279,7 +241,26 @@ export class ClaudeHookService { // Why: SSH remotes use POSIX `.sh` hook paths even when Orca itself is // running on Windows; never derive remote script syntax from local OS. await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) - await writeHooksJsonRemote(sftp, remoteConfigPath, config) + await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig) + + const legacyConfig = await readHooksJsonRemote(sftp, remoteLegacyConfigPath) + if (!legacyConfig) { + return { + agent: 'claude', + state: 'error', + configPath: remoteLegacyConfigPath, + managedHooksPresent: true, + detail: + 'Scoped Claude hooks installed, but could not parse remote Claude settings.json to remove legacy global hooks' + } + } + const { config: nextLegacyConfig, changed } = removeManagedHooks( + legacyConfig, + 'claude-hook.sh' + ) + if (changed) { + await writeHooksJsonRemote(sftp, remoteLegacyConfigPath, nextLegacyConfig) + } return { agent: 'claude', @@ -300,39 +281,14 @@ export class ClaudeHookService { } remove(): AgentHookInstallStatus { - const configPath = getConfigPath() - const config = readHooksJson(configPath) - if (!config) { - return { - agent: 'claude', - state: 'error', - configPath, - managedHooksPresent: false, - detail: 'Could not parse Claude settings.json' - } + const scopedSettingsPath = getScopedSettingsPath() + if (existsSync(scopedSettingsPath)) { + unlinkSync(scopedSettingsPath) } - - const nextHooks = { ...config.hooks } - // Why: same broad matcher as install(), so remove() also cleans up stale - // entries from older builds even if the current scriptPath has moved. - const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) - for (const [eventName, definitions] of Object.entries(nextHooks)) { - // Why: a malformed settings.json entry (non-array value for an event - // name) would make removeManagedCommands throw via definitions.flatMap. - // Skip — we cannot sweep something we cannot parse, and remove() must - // fail open so a broken user config never blocks uninstall. - if (!Array.isArray(definitions)) { - continue - } - const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (cleaned.length === 0) { - delete nextHooks[eventName] - } else { - nextHooks[eventName] = cleaned - } + const legacyStatus = this.removeLegacyGlobalHooks() + if (legacyStatus.state === 'error') { + return legacyStatus } - config.hooks = nextHooks - writeHooksJson(configPath, config) return this.getStatus() } } diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts new file mode 100644 index 000000000..9a54642bf --- /dev/null +++ b/src/main/claude/hook-settings.ts @@ -0,0 +1,124 @@ +import { homedir } from 'os' +import { join } from 'path' +import { ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE } from '../../shared/claude-settings' +import { + createManagedCommandMatcher, + getSharedManagedScriptPath, + removeManagedCommands, + wrapPosixHookCommand, + type HookDefinition, + type HooksConfig +} from '../agent-hooks/installer-utils' + +export const CLAUDE_EVENTS = [ + { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, + { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: PreToolUse gives the dashboard a live readout of the in-flight tool + // (name + input preview) before it completes. + { + eventName: 'PreToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUse', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PostToolUseFailure', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + }, + { + eventName: 'PermissionRequest', + definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + } +] as const + +export function getLegacyConfigPath(): string { + return join(homedir(), '.claude', 'settings.json') +} + +export function getScopedSettingsPath(): string { + return join(homedir(), '.orca', 'agent-hooks', ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE) +} + +export function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' +} + +export function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +export function getRemoteScopedSettingsPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE}` +} + +export function getRemoteLegacyConfigPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.claude/settings.json` +} + +export function getManagedCommand(scriptPath: string): string { + if (process.platform === 'win32') { + // Why: Claude Code runs hooks through Git Bash on Windows; forward slashes + // survive that shell layer while native Windows APIs still accept them. + return scriptPath.replaceAll('\\', '/') + } + return wrapPosixHookCommand(scriptPath) +} + +export function getRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +export function applyManagedHooks( + config: HooksConfig, + command: string, + scriptFileName = getManagedScriptFileName() +): HooksConfig { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + + for (const event of CLAUDE_EVENTS) { + const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + ...event.definition, + hooks: [{ type: 'command', command }] + } + nextHooks[event.eventName] = [...cleaned, definition] + } + + return { ...config, hooks: nextHooks } +} + +export function removeManagedHooks( + config: HooksConfig, + scriptFileName = getManagedScriptFileName() +): { + config: HooksConfig + changed: boolean +} { + const nextHooks = { ...config.hooks } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + let changed = false + + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + changed = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + return { + config: { ...config, hooks: nextHooks }, + changed + } +} diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 880ac16e6..185497cf5 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -32,6 +32,7 @@ vi.mock('node:os', async () => { function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' + const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -116,7 +117,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, - appFontFamily + appFontFamily, + agentStatusHooksEnabled } } diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index a203f85b4..6cae1ebdb 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -25,6 +25,7 @@ vi.mock('node:os', async () => { function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' + const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -109,7 +110,8 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, ...overrides, - appFontFamily + appFontFamily, + agentStatusHooksEnabled } } diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 4d40183e2..d619bb9bc 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -281,7 +281,7 @@ function buildTrustBlock(key: string, hash: string, enabled: boolean): string { // Why: TOML basic strings forbid raw control chars; escape backslash first so // later substitutions don't double-escape the inserted backslashes. -function escapeTomlString(value: string): string { +export function escapeTomlString(value: string): string { return value .replaceAll('\\', '\\\\') .replaceAll('"', '\\"') @@ -495,7 +495,7 @@ function isCompleteTableHeader(line: string): boolean { // half-written config.toml can brick a user's Codex install, so write to // tmp and rename. Random-suffix tmp name avoids cross-process races on // rapid reinstalls. -function writeConfigAtomically(configPath: string, contents: string): void { +export function writeConfigAtomically(configPath: string, contents: string): void { const dir = dirname(configPath) mkdirSync(dir, { recursive: true }) const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) diff --git a/src/main/codex/hook-service.test.ts b/src/main/codex/hook-service.test.ts index a431c17ff..4057d22a1 100644 --- a/src/main/codex/hook-service.test.ts +++ b/src/main/codex/hook-service.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' import { tmpdir } from 'os' import type * as Os from 'os' import { join } from 'path' @@ -74,4 +74,75 @@ describe('CodexHookService', () => { const trustConfig = readFileSync(join(tmpHome, '.codex', 'config.toml'), 'utf-8') expect(trustConfig).toContain(':permission_request:0:0') }) + + it('installs Orca status hooks in the Codex profile instead of global hooks.json', () => { + const service = new CodexHookService() + const status = service.installProfile() + + expect(status.state).toBe('installed') + + const profileConfig = readFileSync( + join(tmpHome, '.codex', 'orca-agent-status.config.toml'), + 'utf-8' + ) + expect(profileConfig).toContain('# BEGIN ORCA AGENT STATUS HOOKS') + expect(profileConfig).toContain('[[hooks.PermissionRequest]]') + expect(profileConfig).toContain(':permission_request:0:0') + expect(profileConfig).toContain('codex-hook') + expect(service.getStatus().state).toBe('not_installed') + }) + + it('keeps the Codex profile hook-only and preserves user provider config', () => { + const service = new CodexHookService() + const baseConfigPath = join(tmpHome, '.codex', 'config.toml') + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + const baseConfig = [ + 'model_provider = "amazon-bedrock"', + '', + '[model_providers.amazon-bedrock]', + 'name = "Amazon Bedrock"', + 'base_url = "https://bedrock-runtime.us-west-2.amazonaws.com"', + 'env_key = "AWS_BEARER_TOKEN_BEDROCK"', + '' + ].join('\n') + writeFileSync(baseConfigPath, baseConfig) + + const status = service.installProfile() + + expect(status.state).toBe('installed') + expect(readFileSync(baseConfigPath, 'utf-8')).toBe(baseConfig) + const profileConfig = readFileSync( + join(tmpHome, '.codex', 'orca-agent-status.config.toml'), + 'utf-8' + ) + expect(profileConfig).toContain('# BEGIN ORCA AGENT STATUS HOOKS') + expect(profileConfig).not.toContain('model_provider') + expect(profileConfig).not.toContain('model_providers') + expect(profileConfig).not.toContain('env_key') + }) + + it('removes only the Orca-managed Codex profile block', () => { + const service = new CodexHookService() + service.installProfile() + const profilePath = join(tmpHome, '.codex', 'orca-agent-status.config.toml') + const withUserConfig = `${readFileSync(profilePath, 'utf-8')}\nmodel = "gpt-5.5"\n` + writeFileSync(profilePath, withUserConfig) + + const status = service.removeProfile() + + expect(status.state).toBe('not_installed') + const remaining = readFileSync(profilePath, 'utf-8') + expect(remaining).not.toContain('ORCA AGENT STATUS HOOKS') + expect(remaining).toContain('model = "gpt-5.5"') + }) + + it('does not create legacy global hooks.json when profile migration cleanup has nothing to remove', () => { + const service = new CodexHookService() + service.installProfile() + + const status = service.remove() + + expect(status.state).toBe('not_installed') + expect(existsSync(join(tmpHome, '.codex', 'hooks.json'))).toBe(false) + }) }) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index c431b121c..f8e18781a 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -1,8 +1,10 @@ /* eslint-disable max-lines -- Why: getStatus + install + remove all share the managed-command and trust-key derivation. Splitting would hide that the three operations must agree on group index, event label, and command bytes. */ +import { existsSync, readFileSync, unlinkSync } from 'fs' import { homedir } from 'os' import { join } from 'path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { ORCA_CODEX_AGENT_STATUS_PROFILE } from '../../shared/codex-profile' import { createManagedCommandMatcher, buildWindowsAgentHookPostCommand, @@ -29,6 +31,8 @@ import { removeHookTrustEntries, upsertHookTrustEntriesInContent, upsertHookTrustEntries, + escapeTomlString, + writeConfigAtomically, type CodexEventLabel, type CodexHookTrustState, type CodexTrustEntry @@ -56,6 +60,10 @@ function getCodexConfigTomlPath(): string { return join(homedir(), '.codex', 'config.toml') } +function getCodexProfileTomlPath(): string { + return join(homedir(), '.codex', `${ORCA_CODEX_AGENT_STATUS_PROFILE}.config.toml`) +} + // Why: Codex's hash key uses the snake_case event label (see // codex-rs/hooks/src/lib.rs::hook_event_key_label). Our hooks.json uses the // PascalCase serde-rename. Map between them at one place so the trust-write @@ -69,6 +77,9 @@ const CODEX_EVENT_LABEL: Record<(typeof CODEX_EVENTS)[number], CodexEventLabel> Stop: 'stop' } +const ORCA_PROFILE_BLOCK_START = '# BEGIN ORCA AGENT STATUS HOOKS' +const ORCA_PROFILE_BLOCK_END = '# END ORCA AGENT STATUS HOOKS' + function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'codex-hook.cmd' : 'codex-hook.sh' } @@ -132,7 +143,198 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } +function findManagedProfileBlock(content: string): string | null { + const start = content.indexOf(ORCA_PROFILE_BLOCK_START) + if (start === -1) { + return null + } + const endMarker = content.indexOf(ORCA_PROFILE_BLOCK_END, start) + const end = endMarker === -1 ? content.length : endMarker + ORCA_PROFILE_BLOCK_END.length + return content.slice(start, end) +} + +function stripManagedProfileBlock(content: string): string { + const start = content.indexOf(ORCA_PROFILE_BLOCK_START) + if (start === -1) { + return content + } + const endMarker = content.indexOf(ORCA_PROFILE_BLOCK_END, start) + const end = endMarker === -1 ? content.length : endMarker + ORCA_PROFILE_BLOCK_END.length + const before = content.slice(0, start).replace(/[ \t]*(?:\r?\n)*$/, '') + const after = content.slice(end).replace(/^(?:\r?\n)+/, '') + if (!before) { + return after + } + if (!after) { + return before.endsWith('\n') ? before : `${before}\n` + } + return `${before}\n\n${after}` +} + +function appendManagedProfileBlock(existing: string, block: string): string { + const trimmedExisting = stripManagedProfileBlock(existing).replace(/[ \t]*(?:\r?\n)*$/, '') + if (!trimmedExisting) { + return `${block}\n` + } + return `${trimmedExisting}\n\n${block}\n` +} + +function buildProfileTrustEntry( + profilePath: string, + eventName: (typeof CODEX_EVENTS)[number], + command: string +): CodexTrustEntry { + return { + sourcePath: profilePath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: 0, + handlerIndex: 0, + command + } +} + +function buildManagedProfileBlock(profilePath: string, command: string): string { + const lines = [ + ORCA_PROFILE_BLOCK_START, + '# Managed by Orca so only Codex sessions launched from Orca load agent-status hooks.' + ] + for (const eventName of CODEX_EVENTS) { + const entry = buildProfileTrustEntry(profilePath, eventName, command) + lines.push( + `[hooks.state."${escapeTomlString(computeTrustKey(entry))}"]`, + 'enabled = true', + `trusted_hash = "${escapeTomlString(computeTrustedHash(entry))}"`, + '', + `[[hooks.${eventName}]]`, + `[[hooks.${eventName}.hooks]]`, + 'type = "command"', + `command = "${escapeTomlString(command)}"`, + '' + ) + } + lines.push(ORCA_PROFILE_BLOCK_END) + return lines.join('\n') +} + export class CodexHookService { + getProfileStatus(): AgentHookInstallStatus { + const configPath = getCodexProfileTomlPath() + if (!existsSync(configPath)) { + return { + agent: 'codex', + state: 'not_installed', + configPath, + managedHooksPresent: false, + detail: null + } + } + + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + const content = readFileSync(configPath, 'utf-8') + const block = findManagedProfileBlock(content) + if (!block) { + return { + agent: 'codex', + state: 'not_installed', + configPath, + managedHooksPresent: false, + detail: null + } + } + + const escapedCommandLine = `command = "${escapeTomlString(command)}"` + const missing = CODEX_EVENTS.filter( + (eventName) => + !block.includes(`[[hooks.${eventName}]]`) || !block.includes(escapedCommandLine) + ) + + let trustEntries: Map + let trustReadError: string | null = null + try { + trustEntries = readHookTrustEntries(configPath) + } catch (error) { + trustEntries = new Map() + trustReadError = error instanceof Error ? error.message : String(error) + } + + const trustMissing: string[] = [] + const disabled: string[] = [] + for (const eventName of CODEX_EVENTS) { + const entry = buildProfileTrustEntry(configPath, eventName, command) + const actualState = trustEntries.get(computeTrustKey(entry)) + if (actualState?.trustedHash !== computeTrustedHash(entry)) { + trustMissing.push(eventName) + } else if (actualState.enabled === false) { + disabled.push(eventName) + } + } + + if ( + missing.length === 0 && + trustMissing.length === 0 && + disabled.length === 0 && + !trustReadError + ) { + return { + agent: 'codex', + state: 'installed', + configPath, + managedHooksPresent: true, + detail: null + } + } + + const parts: string[] = [] + if (missing.length > 0) { + parts.push(`Profile hook missing for events: ${missing.join(', ')}`) + } + if (trustReadError) { + parts.push(`Trust entries unverifiable: ${trustReadError}`) + } else if (trustMissing.length > 0) { + parts.push(`Trust entry missing or stale for events: ${trustMissing.join(', ')}`) + } + if (disabled.length > 0) { + parts.push(`Managed hook disabled for events: ${disabled.join(', ')}`) + } + return { + agent: 'codex', + state: 'partial', + configPath, + managedHooksPresent: true, + detail: parts.join('; ') + } + } + + installProfile(): AgentHookInstallStatus { + const configPath = getCodexProfileTomlPath() + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + const existing = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '' + const next = appendManagedProfileBlock(existing, buildManagedProfileBlock(configPath, command)) + writeManagedScript(scriptPath, getManagedScript()) + writeConfigAtomically(configPath, next) + return this.getProfileStatus() + } + + removeProfile(): AgentHookInstallStatus { + const configPath = getCodexProfileTomlPath() + if (!existsSync(configPath)) { + return this.getProfileStatus() + } + const existing = readFileSync(configPath, 'utf-8') + const next = stripManagedProfileBlock(existing) + if (next === existing) { + return this.getProfileStatus() + } + if (next.trim().length === 0) { + unlinkSync(configPath) + } else { + writeConfigAtomically(configPath, next) + } + return this.getProfileStatus() + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -432,8 +634,79 @@ export class CodexHookService { } } + async installRemoteProfile( + sftp: SFTPWrapper, + remoteHome: string + ): Promise { + const normalizedHome = remoteHome.replace(/\/$/, '') + const remoteProfilePath = `${normalizedHome}/.codex/${ORCA_CODEX_AGENT_STATUS_PROFILE}.config.toml` + const remoteScriptPath = `${normalizedHome}/.orca/agent-hooks/codex-hook.sh` + const remoteGlobalConfigPath = `${normalizedHome}/.codex/hooks.json` + try { + const command = wrapPosixHookCommand(remoteScriptPath) + const existingProfile = (await readTextFileRemote(sftp, remoteProfilePath)) ?? '' + const nextProfile = appendManagedProfileBlock( + existingProfile, + buildManagedProfileBlock(remoteProfilePath, command) + ) + + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + if (nextProfile !== existingProfile) { + await writeTextFileRemoteAtomic(sftp, remoteProfilePath, nextProfile) + } + + // Why: profile-scoped Codex hooks keep Orca out of external remote + // `codex` sessions. Sweep legacy global Orca entries left by older + // remote installers so the profile is the only active Codex hook source. + const existingGlobalConfig = await readTextFileRemote(sftp, remoteGlobalConfigPath) + if (existingGlobalConfig !== null) { + const globalConfig = await readHooksJsonRemote(sftp, remoteGlobalConfigPath) + if (globalConfig) { + let removedGlobalHooks = false + const nextHooks = { ...globalConfig.hooks } + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + removedGlobalHooks = true + } + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + if (removedGlobalHooks) { + globalConfig.hooks = nextHooks + await writeHooksJsonRemote(sftp, remoteGlobalConfigPath, globalConfig) + } + } + } + + return { + agent: 'codex', + state: 'installed', + configPath: remoteProfilePath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'codex', + state: 'error', + configPath: remoteProfilePath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + remove(): AgentHookInstallStatus { const configPath = getConfigPath() + const configExists = existsSync(configPath) const config = readHooksJson(configPath) if (!config) { return { @@ -446,6 +719,7 @@ export class CodexHookService { } const nextHooks = { ...config.hooks } + let removedManagedHooks = false // Why: same broad matcher as install(), so remove() also cleans up stale // entries from older builds even if the current scriptPath has moved. const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName()) @@ -457,14 +731,19 @@ export class CodexHookService { continue } const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + removedManagedHooks = true + } if (cleaned.length === 0) { delete nextHooks[eventName] } else { nextHooks[eventName] = cleaned } } - config.hooks = nextHooks - writeHooksJson(configPath, config) + if (configExists && removedManagedHooks) { + config.hooks = nextHooks + writeHooksJson(configPath, config) + } // Why: also drop our trust entries so config.toml doesn't accumulate dead // [hooks.state."..."] blocks across install/remove cycles. Best-effort — diff --git a/src/main/index.ts b/src/main/index.ts index 28360a1f4..daa9c0edc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -24,6 +24,11 @@ import { startSpan } from './observability/tracer' import { registerMobileHandlers } from './ipc/mobile' import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client' import { runManagedHookInstallers } from './agent-hooks/install-telemetry' +import { + isAgentStatusHooksEnabled, + MANAGED_AGENT_HOOK_INSTALLERS, + removeManagedAgentHooks +} from './agent-hooks/managed-agent-hook-controls' import { initCohortClassifier } from './telemetry/cohort-classifier' import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' import { resolveConsent } from './telemetry/consent' @@ -60,15 +65,6 @@ import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service import { StarNagService } from './star-nag/service' import { agentHookServer } from './agent-hooks/server' import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { claudeHookService } from './claude/hook-service' -import { codexHookService } from './codex/hook-service' -import { geminiHookService } from './gemini/hook-service' -import { antigravityHookService } from './antigravity/hook-service' -import { cursorHookService } from './cursor/hook-service' -import { droidHookService } from './droid/hook-service' -import { grokHookService } from './grok/hook-service' -import { copilotHookService } from './copilot/hook-service' -import { hermesHookService } from './hermes/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, @@ -1015,23 +1011,13 @@ app.whenReady().then(async () => { }) ) nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why: managed hook installation mutates user-global agent config. Each - // installer runs inside its own try/catch so a malformed local config - // (e.g. corrupted ~/.claude/settings.json) cannot brick Orca startup. - // The agent label travels with each installer so the catch can attribute - // the failure in the `agent_hook_install_failed` telemetry event. - const managedHookInstallers = [ - ['claude', () => claudeHookService.install()], - ['codex', () => codexHookService.install()], - ['gemini', () => geminiHookService.install()], - ['antigravity', () => antigravityHookService.install()], - ['cursor', () => cursorHookService.install()], - ['droid', () => droidHookService.install()], - ['grok', () => grokHookService.install()], - ['copilot', () => copilotHookService.install()], - ['hermes', () => hermesHookService.install()] - ] as const - runManagedHookInstallers(managedHookInstallers) + // Why: the persisted off switch must run before any auto-install path so + // users who removed Orca-managed hooks do not see them silently reappear on launch. + if (isAgentStatusHooksEnabled(store.getSettings())) { + runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS) + } else { + removeManagedAgentHooks() + } app.on('child-process-gone', (_event, details) => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 79246f38b..235d726e7 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -99,7 +99,7 @@ export function registerAgentHookHandlers(): void { }) ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => { try { - return codexHookService.getStatus() + return codexHookService.getProfileStatus() } catch (err) { return { agent: 'codex', diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 807bf79e5..e672e0355 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -20,6 +20,7 @@ const { spawnMock, openCodeBuildPtyEnvMock, openCodeClearPtyMock, + claudeBuildPtyEnvMock, buildAgentHookEnvMock, clearAgentHookPaneStateMock, registerPaneKeyAliasMock, @@ -49,6 +50,7 @@ const { getPathMock: vi.fn(), spawnMock: vi.fn(), openCodeBuildPtyEnvMock: vi.fn(), + claudeBuildPtyEnvMock: vi.fn(), isPwshAvailableMock: vi.fn(), openCodeClearPtyMock: vi.fn(), buildAgentHookEnvMock: vi.fn(), @@ -103,6 +105,12 @@ vi.mock('../opencode/hook-service', () => ({ } })) +vi.mock('../claude/hook-service', () => ({ + claudeHookService: { + buildPtyEnv: claudeBuildPtyEnvMock + } +})) + vi.mock('../agent-hooks/server', () => ({ agentHookServer: { buildPtyEnv: buildAgentHookEnvMock, @@ -187,12 +195,14 @@ describe('registerPtyHandlers', () => { const savedPiAgentDir = process.env.PI_CODING_AGENT_DIR const savedOrcaPiAgentDir = process.env.ORCA_PI_CODING_AGENT_DIR const savedOrcaPiSourceAgentDir = process.env.ORCA_PI_SOURCE_AGENT_DIR + const savedOrcaClaudeSettings = process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS beforeEach(() => { delete process.env.OPENCODE_CONFIG_DIR delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR delete process.env.ORCA_OPENCODE_CONFIG_DIR delete process.env.ORCA_AGENT_HOOK_ENDPOINT + delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS delete process.env.PI_CODING_AGENT_DIR delete process.env.ORCA_PI_SOURCE_AGENT_DIR delete process.env.ORCA_PI_CODING_AGENT_DIR @@ -212,6 +222,7 @@ describe('registerPtyHandlers', () => { spawnMock.mockReset() openCodeBuildPtyEnvMock.mockReset() openCodeClearPtyMock.mockReset() + claudeBuildPtyEnvMock.mockReset() buildAgentHookEnvMock.mockReset() clearAgentHookPaneStateMock.mockReset() registerPaneKeyAliasMock.mockReset() @@ -248,6 +259,9 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_PORT: '5678', ORCA_AGENT_HOOK_TOKEN: 'agent-token' }) + claudeBuildPtyEnvMock.mockReturnValue({ + ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/orca-claude-settings.json' + }) piBuildPtyEnvMock.mockImplementation((_ptyId: string, existingAgentDir?: string) => ({ PI_CODING_AGENT_DIR: existingAgentDir ? '/tmp/orca-pi-agent-overlay' @@ -298,6 +312,11 @@ describe('registerPtyHandlers', () => { } else { process.env.ORCA_PI_SOURCE_AGENT_DIR = savedOrcaPiSourceAgentDir } + if (savedOrcaClaudeSettings === undefined) { + delete process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS + } else { + process.env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS = savedOrcaClaudeSettings + } }) function createMockProc() { @@ -340,7 +359,7 @@ describe('registerPtyHandlers', () => { argsEnv?: Record, processEnvOverrides?: Record, getSelectedCodexHomePath?: () => string | null, - getSettings?: () => { enableGitHubAttribution: boolean } + getSettings?: () => { enableGitHubAttribution?: boolean; agentStatusHooksEnabled?: boolean } ): Promise> { const savedEnv: Record = {} if (processEnvOverrides) { @@ -498,6 +517,41 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/tmp/user-opencode-config') }) + it('restores user OpenCode config when agent status hooks are disabled in a nested Orca shell', async () => { + const env = await spawnAndGetEnv( + { + OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }) + ) + + expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/user-opencode-config') + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + }) + + it('strips inherited OpenCode overlay env when agent status hooks are disabled without a source dir', async () => { + const env = await spawnAndGetEnv( + { + OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay', + ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }) + ) + + expect(openCodeBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + }) + it('reproduces issue #1534: GUI-launched Orca mirrors zshrc-only OpenCode config', async () => { // Why: the reporter's app process did not inherit OPENCODE_CONFIG_DIR; // their interactive zsh startup later exported a company config repo. @@ -548,6 +602,24 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBe('/tmp/user-pi-agent') }) + it('restores user Pi config when agent status hooks are disabled in a nested Orca shell', async () => { + const env = await spawnAndGetEnv( + { + PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_CODING_AGENT_DIR: '/tmp/parent-orca-pi-overlay', + ORCA_PI_SOURCE_AGENT_DIR: '/tmp/user-pi-agent' + }, + undefined, + undefined, + () => ({ agentStatusHooksEnabled: false }) + ) + + expect(piBuildPtyEnvMock).not.toHaveBeenCalled() + expect(env.PI_CODING_AGENT_DIR).toBe('/tmp/user-pi-agent') + expect(env.ORCA_PI_CODING_AGENT_DIR).toBeUndefined() + expect(env.ORCA_PI_SOURCE_AGENT_DIR).toBeUndefined() + }) + it('mirrors Pi config exported only by shell startup files', async () => { readFileSyncMock.mockImplementation((path: string) => path.endsWith('.zshrc') ? 'export PI_CODING_AGENT_DIR="$HOME/.config/pi-agent"\n' : '' @@ -576,8 +648,10 @@ describe('registerPtyHandlers', () => { // both route through. The handler's separate ad-hoc injection (which // used to cause a double-call for local spawns) is gone. expect(buildAgentHookEnvMock).toHaveBeenCalledTimes(1) + expect(claudeBuildPtyEnvMock).toHaveBeenCalledTimes(1) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json') }) it('strips stale inherited hook receiver env before injecting this runtime', async () => { @@ -586,7 +660,8 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_TOKEN: 'stale-token', ORCA_AGENT_HOOK_ENV: 'production', ORCA_AGENT_HOOK_VERSION: 'stale-version', - ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env', + ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/stale-claude-settings.json' }) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') @@ -594,6 +669,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json') }) it('does not leak inherited hook receiver env if the hook server is unavailable', async () => { @@ -604,7 +680,8 @@ describe('registerPtyHandlers', () => { ORCA_AGENT_HOOK_TOKEN: 'stale-token', ORCA_AGENT_HOOK_ENV: 'production', ORCA_AGENT_HOOK_VERSION: 'stale-version', - ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env' + ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env', + ORCA_CLAUDE_AGENT_STATUS_SETTINGS: '/tmp/stale-claude-settings.json' }) expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() @@ -612,6 +689,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_HOOK_ENV).toBeUndefined() expect(env.ORCA_AGENT_HOOK_VERSION).toBeUndefined() expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined() + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json') }) it('prepends local git/gh attribution shims when attribution is enabled', async () => { @@ -804,6 +882,7 @@ describe('registerPtyHandlers', () => { const env = await daemonSpawnAndGetEnv({}) expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678') expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBe('/tmp/orca-claude-settings.json') }) it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => { @@ -1094,6 +1173,7 @@ describe('registerPtyHandlers', () => { // worst a credential leak. expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + expect(env.ORCA_CLAUDE_AGENT_STATUS_SETTINGS).toBeUndefined() expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 806941bf7..67409c9bf 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -10,8 +10,11 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings } from '../../shared/types' +import { ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV } from '../../shared/claude-settings' +import { claudeHookService } from '../claude/hook-service' import { openCodeHookService } from '../opencode/hook-service' import { agentHookServer } from '../agent-hooks/server' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { piTitlebarExtensionService } from '../pi/titlebar-extension-service' import { isPwshAvailable } from '../pwsh' import { LocalPtyProvider } from '../providers/local-pty-provider' @@ -87,7 +90,8 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_TOKEN', 'ORCA_AGENT_HOOK_ENV', 'ORCA_AGENT_HOOK_VERSION', - 'ORCA_AGENT_HOOK_ENDPOINT' + 'ORCA_AGENT_HOOK_ENDPOINT', + ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV ] as const export function getPtyIdForPaneKey(paneKey: string): string | undefined { @@ -250,12 +254,35 @@ export type BuildPtyHostEnvOptions = { userDataPath: string selectedCodexHomePath: string | null githubAttributionEnabled: boolean + agentStatusHooksEnabled: boolean } function readInheritedPath(baseEnv: Record): string { return baseEnv.PATH ?? process.env.PATH ?? process.env.Path ?? '' } +// Why: when agent status is disabled, a nested Orca terminal can still pass +// through a prior PTY's OpenCode/Pi overlay env. Restore the user's original +// source dir when Orca recorded one, otherwise strip only values known to be ours. +function restoreOrStripOverlayEnv( + baseEnv: Record, + keys: { + primary: string + overlay: string + source: string + } +): void { + const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] + const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] + if (sourceValue) { + baseEnv[keys.primary] = sourceValue + } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { + delete baseEnv[keys.primary] + } + delete baseEnv[keys.overlay] + delete baseEnv[keys.source] +} + /** * Mutates `baseEnv` in place with all host-local PTY env vars and returns it. * @@ -300,23 +327,31 @@ export function buildPtyHostEnv( baseEnv.SHELL ?? process.env.SHELL ) - // Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user - // value cannot coexist with an Orca-only injection. Hand the user's value - // (when present) to the hook service and let it materialize a per-PTY - // mirror overlay that lets the user's plugins and Orca's status plugin - // load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See - // docs/opencode-config-dir-collision.md. - Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) - if (baseEnv.OPENCODE_CONFIG_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready - // wrappers restore this PTY-scoped value after user startup files run. - baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR - if (preexistingOpenCodeConfigDir) { - // Why: terminals launched from another Orca terminal inherit the overlay - // as OPENCODE_CONFIG_DIR; keep the original source so overlays do not - // mirror overlays and drop the user's real config. - baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + if (opts.agentStatusHooksEnabled) { + // Why: OPENCODE_CONFIG_DIR is a singular path, not a colon-list, so a user + // value cannot coexist with an Orca-only injection. Hand the user's value + // (when present) to the hook service and let it materialize a per-PTY + // mirror overlay that lets the user's plugins and Orca's status plugin + // load together — same pattern Pi uses below for PI_CODING_AGENT_DIR. See + // docs/opencode-config-dir-collision.md. + Object.assign(baseEnv, openCodeHookService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + if (baseEnv.OPENCODE_CONFIG_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready + // wrappers restore this PTY-scoped value after user startup files run. + baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR + if (preexistingOpenCodeConfigDir) { + // Why: terminals launched from another Orca terminal inherit the overlay + // as OPENCODE_CONFIG_DIR; keep the original source so overlays do not + // mirror overlays and drop the user's real config. + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR = preexistingOpenCodeConfigDir + } } + } else { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }) } // Why: Claude/Codex native hooks run inside the shell process, so Orca @@ -329,7 +364,10 @@ export function buildPtyHostEnv( for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) { delete baseEnv[key] } - Object.assign(baseEnv, agentHookServer.buildPtyEnv()) + if (opts.agentStatusHooksEnabled) { + Object.assign(baseEnv, agentHookServer.buildPtyEnv()) + Object.assign(baseEnv, claudeHookService.buildPtyEnv()) + } // Why: PI_CODING_AGENT_DIR owns Pi's full config/session root. Build a // PTY-scoped overlay from the caller's chosen root so Pi sessions keep @@ -339,16 +377,24 @@ export function buildPtyHostEnv( // restarts by design. A future reader should NOT "simplify" id allocation // back to a fresh UUID per spawn; that would discard user Pi state on // every daemon reconnect. - Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir)) - if (baseEnv.PI_CODING_AGENT_DIR) { - // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready - // wrappers restore this PTY-scoped value after user startup files run. - baseEnv.ORCA_PI_CODING_AGENT_DIR = baseEnv.PI_CODING_AGENT_DIR - if (preexistingPiAgentDir) { - // Why: preserve the original Pi root across nested Orca terminals; the - // public env var is intentionally restored to the current PTY overlay. - baseEnv.ORCA_PI_SOURCE_AGENT_DIR = preexistingPiAgentDir + if (opts.agentStatusHooksEnabled) { + Object.assign(baseEnv, piTitlebarExtensionService.buildPtyEnv(id, preexistingPiAgentDir)) + if (baseEnv.PI_CODING_AGENT_DIR) { + // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready + // wrappers restore this PTY-scoped value after user startup files run. + baseEnv.ORCA_PI_CODING_AGENT_DIR = baseEnv.PI_CODING_AGENT_DIR + if (preexistingPiAgentDir) { + // Why: preserve the original Pi root across nested Orca terminals; the + // public env var is intentionally restored to the current PTY overlay. + baseEnv.ORCA_PI_SOURCE_AGENT_DIR = preexistingPiAgentDir + } } + } else { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'PI_CODING_AGENT_DIR', + overlay: 'ORCA_PI_CODING_AGENT_DIR', + source: 'ORCA_PI_SOURCE_AGENT_DIR' + }) } // Why: Codex account switching now materializes auth into one shared @@ -625,7 +671,8 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) // Why: agents need their own terminal handle at process start so they // can self-identify in orchestration messages without an extra RPC. @@ -935,7 +982,8 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) } @@ -1309,7 +1357,8 @@ export function registerPtyHandlers( isPackaged: app.isPackaged, userDataPath: app.getPath('userData'), selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null, - githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false + githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false, + agentStatusHooksEnabled: isAgentStatusHooksEnabled(getSettings?.()) }) } catch (err) { // Why: buildPtyHostEnv has filesystem side-effects (Pi overlay diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index c8bc4103d..209a1e2c1 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -8,6 +8,7 @@ import { track } from '../telemetry/client' import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events' import type { AgentAwakeService } from '../agent-awake-service' import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory' +import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' // Why: the whitelist is the source-of-truth for which keys we emit on. Casting // to a Set once at module load lets the IPC handler's per-key membership @@ -55,6 +56,16 @@ export function registerSettingsHandlers( if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) { agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun) } + if ( + 'agentStatusHooksEnabled' in sanitizedArgs && + before.agentStatusHooksEnabled !== result.agentStatusHooksEnabled + ) { + try { + applyAgentStatusHooksEnabled(result.agentStatusHooksEnabled) + } catch (error) { + console.warn('[settings] failed to apply agentStatusHooksEnabled:', error) + } + } if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) { rebuildAppMenu() } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 34bd5fe8a..dcacd78c5 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -31,6 +31,7 @@ import { unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { appendOrcaCodexAgentStatusProfile } from '../../shared/codex-profile' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' @@ -6922,7 +6923,7 @@ describe('OrcaRuntimeService', () => { 1, expect.objectContaining({ cwd: '/tmp/workspaces/runtime-startup-setup-split', - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), worktreeId: result.worktree.id }) ) @@ -7025,7 +7026,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/tmp/workspaces/runtime-explicit-draft', - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), worktreeId: result.worktree.id }) ) @@ -7189,7 +7190,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-startup-draft', - command: `claude --prefill '${draftUrl}'`, + command: `claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json" --prefill '${draftUrl}'`, connectionId: 'ssh-1', worktreeId: result.worktree.id }) @@ -7297,7 +7298,7 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/remote/mobile-codex-draft', - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), connectionId: 'ssh-1', worktreeId: result.worktree.id }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 06a7150b9..cddf7aee2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -62,6 +62,7 @@ import { markCopilotFolderTrusted, markCursorWorkspaceTrusted } from '../agent-trust-presets' +import { applyAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { upsertProjectTrustLevelInContent } from '../codex/config-toml-trust' import { isWindowsAbsolutePathLike, @@ -421,6 +422,7 @@ type RuntimeStore = { branchPrefixCustom: string defaultTuiAgent?: GlobalSettings['defaultTuiAgent'] agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled'] defaultTaskSource?: GlobalSettings['defaultTaskSource'] defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset'] visibleTaskProviders?: GlobalSettings['visibleTaskProviders'] @@ -1295,6 +1297,7 @@ export class OrcaRuntimeService { GlobalSettings, | 'defaultTuiAgent' | 'agentCmdOverrides' + | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' @@ -1309,6 +1312,7 @@ export class OrcaRuntimeService { return { defaultTuiAgent: settings.defaultTuiAgent ?? null, agentCmdOverrides: settings.agentCmdOverrides ?? {}, + agentStatusHooksEnabled: settings.agentStatusHooksEnabled !== false, defaultTaskSource: settings.defaultTaskSource ?? 'github', defaultTaskViewPreset: settings.defaultTaskViewPreset ?? 'issues', visibleTaskProviders: settings.visibleTaskProviders ?? ['github', 'gitlab', 'linear'], @@ -1321,6 +1325,7 @@ export class OrcaRuntimeService { updateClientSettings( updates: Pick< Partial, + | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'defaultRepoSelection' @@ -1331,6 +1336,7 @@ export class OrcaRuntimeService { GlobalSettings, | 'defaultTuiAgent' | 'agentCmdOverrides' + | 'agentStatusHooksEnabled' | 'defaultTaskSource' | 'defaultTaskViewPreset' | 'visibleTaskProviders' @@ -1338,10 +1344,17 @@ export class OrcaRuntimeService { | 'defaultLinearTeamSelection' | 'githubProjects' > { - if (!this.store?.updateSettings) { + if (!this.store?.getSettings || !this.store.updateSettings) { throw new Error('runtime_unavailable') } + const before = this.store.getSettings().agentStatusHooksEnabled !== false this.store.updateSettings(updates) + if ( + typeof updates.agentStatusHooksEnabled === 'boolean' && + before !== updates.agentStatusHooksEnabled + ) { + applyAgentStatusHooksEnabled(updates.agentStatusHooksEnabled) + } return this.getClientSettings() } @@ -6717,7 +6730,9 @@ export class OrcaRuntimeService { agent, draft: content, cmdOverrides: settings.agentCmdOverrides ?? {}, - platform: agentLaunchPlatform + platform: agentLaunchPlatform, + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false }) if (draftLaunchPlan) { return { @@ -6734,7 +6749,9 @@ export class OrcaRuntimeService { prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, platform: agentLaunchPlatform, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false }) if (!startupPlan) { return null diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 0098722c1..ca147a844 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -80,6 +80,7 @@ const SettingsUpdate = z defaultTaskViewPreset: z .enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all']) .optional(), + agentStatusHooksEnabled: z.boolean().optional(), defaultRepoSelection: z.array(z.string()).nullable().optional(), defaultLinearTeamSelection: z.array(z.string()).nullable().optional(), githubProjects: GitHubProjectSettings.optional() diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 4d04c1198..85f4793fc 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -19,6 +19,7 @@ import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' import { installRemoteManagedAgentHooks } from '../agent-hooks/remote-managed-hook-installers' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD, AGENT_HOOK_NOTIFICATION_METHOD, @@ -467,7 +468,7 @@ export class SshRelaySession { // configs before registering the PTY provider so newly spawned agent panes // report status from their first prompt. private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled()) { + if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return } @@ -519,7 +520,7 @@ export class SshRelaySession { // they upgrade. Hook-script-based agents use a separate explicit remote // installer flow because that mutates user-owned agent config files. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled()) { + if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { return } try { @@ -548,6 +549,11 @@ export class SshRelaySession { } } + private areAgentStatusHooksEnabled(): boolean { + const store = this.store as { getSettings?: Store['getSettings'] } + return isAgentStatusHooksEnabled(store.getSettings?.()) + } + private wireUpRemoteWorkspaceEvents(mux: SshChannelMultiplexer): void { mux.onNotification((method, params) => { notifyRemoteWorkspaceHandlers(this.targetId, method, params) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 89c33154f..5cec9cc3f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -48,7 +48,9 @@ export function FloatingTerminalWindowControls({ prompt: '', cmdOverrides: state.settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: state.settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: state.settings?.agentStatusHooksEnabled !== false }) if (!startupPlan) { toast.error(`Could not build launch command for ${defaultAgentLabel ?? defaultAgent}.`) diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index dc2c49e85..c2f149287 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { appendOrcaCodexAgentStatusProfile } from '../../../../shared/codex-profile' import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { buildDismissedOnboardingFolderAgentStartup, @@ -14,7 +15,7 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index 25dcdc5d8..c1eabf5d7 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -4,9 +4,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/types' import { useAppStore } from '../../store' +import { AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy' import { getAgentAwakeDescription } from './agent-awake-copy' import { AgentAwakeSetting } from './AgentAwakeSetting' -import { AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' +import { AgentStatusHooksSetting, AgentsPane, AGENTS_PANE_SEARCH_ENTRIES } from './AgentsPane' import { matchesSettingsSearch } from './settings-search' type ReactElementLike = { @@ -38,10 +39,10 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { } } -function findSwitch(node: unknown): ReactElementLike { +function findSwitch(node: unknown, ariaLabel: string): ReactElementLike { let found: ReactElementLike | null = null visit(node, (entry) => { - if (entry.props.role === 'switch') { + if (entry.props.role === 'switch' && entry.props['aria-label'] === ariaLabel) { found = entry } }) @@ -51,6 +52,23 @@ function findSwitch(node: unknown): ReactElementLike { return found } +function findSwitchRow(node: unknown, ariaLabel: string): ReactElementLike { + let found: ReactElementLike | null = null + visit(node, (entry) => { + if ( + entry.props.ariaLabel === ariaLabel && + typeof entry.props.checked === 'boolean' && + typeof entry.props.onChange === 'function' + ) { + found = entry + } + }) + if (!found) { + throw new Error('switch row not found') + } + return found +} + describe('AgentsPane', () => { beforeEach(() => { useAppStore.setState({ @@ -87,7 +105,7 @@ describe('AgentsPane', () => { updateSettings }) - const keepAwakeSwitch = findSwitch(element) + const keepAwakeSwitch = findSwitch(element, 'Keep computer awake while agents are working') expect(keepAwakeSwitch.props['aria-label']).toBe('Keep computer awake while agents are working') expect(keepAwakeSwitch.props['aria-checked']).toBe(false) @@ -99,9 +117,37 @@ describe('AgentsPane', () => { }) }) + it('toggles the agent status hook setting with the next value', () => { + const updateSettings = vi.fn() + const element = AgentStatusHooksSetting({ + settings: { + ...getDefaultSettings('/tmp'), + agentStatusHooksEnabled: true + }, + updateSettings + }) + + const statusSwitch = findSwitchRow(element, AGENT_STATUS_HOOKS_TITLE) + expect(statusSwitch.props.checked).toBe(true) + + const onChange = statusSwitch.props.onChange as () => void + onChange() + + expect(updateSettings).toHaveBeenCalledWith({ + agentStatusHooksEnabled: false + }) + }) + it('includes awake and sleep search metadata for the setting', () => { expect(matchesSettingsSearch('awake', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) expect(matchesSettingsSearch('sleep', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) expect(matchesSettingsSearch('lid', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) }) + + it('includes hook search metadata for the status setting', () => { + expect(matchesSettingsSearch('hooks', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) + expect(matchesSettingsSearch('waiting', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) + expect(matchesSettingsSearch('scoped', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) + expect(matchesSettingsSearch('codex', AGENTS_PANE_SEARCH_ENTRIES)).toBe(true) + }) }) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index defcd632a..0c9cbf85c 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -7,7 +7,8 @@ import { Button } from '../ui/button' import { Input } from '../ui/input' import { cn } from '@/lib/utils' import { AgentAwakeSetting } from './AgentAwakeSetting' -import { SettingsBadge, SettingsSubsectionHeader } from './SettingsFormControls' +import { AGENT_STATUS_HOOKS_DESCRIPTION, AGENT_STATUS_HOOKS_TITLE } from './agent-status-hooks-copy' +import { SettingsBadge, SettingsSubsectionHeader, SettingsSwitchRow } from './SettingsFormControls' export { AGENTS_PANE_SEARCH_ENTRIES } from './agents-search' @@ -316,6 +317,8 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React + + {detectedAgents.length > 0 && ( @@ -400,3 +403,25 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React ) } + +export function AgentStatusHooksSetting({ + settings, + updateSettings +}: AgentsPaneProps): React.JSX.Element { + const enabled = settings.agentStatusHooksEnabled !== false + return ( +
+ + updateSettings({ + agentStatusHooksEnabled: !enabled + }) + } + ariaLabel={AGENT_STATUS_HOOKS_TITLE} + /> +
+ ) +} diff --git a/src/renderer/src/components/settings/agent-status-hooks-copy.ts b/src/renderer/src/components/settings/agent-status-hooks-copy.ts new file mode 100644 index 000000000..99c9cfb68 --- /dev/null +++ b/src/renderer/src/components/settings/agent-status-hooks-copy.ts @@ -0,0 +1,20 @@ +export const AGENT_STATUS_HOOKS_TITLE = 'Agent status hooks' + +export const AGENT_STATUS_HOOKS_DESCRIPTION = + 'Shows working, waiting, and done states in Orca. For Claude and Codex, Orca uses scoped settings and profiles so terminal sessions outside Orca keep your existing config. Turn off to remove Orca-managed hooks and stop reinstalling them.' + +export const AGENT_STATUS_HOOKS_SEARCH_KEYWORDS = [ + 'hooks', + 'status', + 'working', + 'waiting', + 'done', + 'remove', + 'restore', + 'scoped', + 'profile', + 'settings', + 'config', + 'claude', + 'codex' +] diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index 279481783..1095348de 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -4,6 +4,11 @@ import { getAgentAwakeDescription, getAgentAwakeSearchKeywords } from './agent-awake-copy' +import { + AGENT_STATUS_HOOKS_DESCRIPTION, + AGENT_STATUS_HOOKS_SEARCH_KEYWORDS, + AGENT_STATUS_HOOKS_TITLE +} from './agent-status-hooks-copy' export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { @@ -45,6 +50,11 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'detected' ] }, + { + title: AGENT_STATUS_HOOKS_TITLE, + description: AGENT_STATUS_HOOKS_DESCRIPTION, + keywords: AGENT_STATUS_HOOKS_SEARCH_KEYWORDS + }, { title: AGENT_AWAKE_TITLE, description: getAgentAwakeDescription(), diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 72ffda4e7..43e5b2175 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -1792,7 +1792,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent: tuiAgent, prompt: startupPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) // Why: thread agent_started telemetry through the queued startup so @@ -1869,6 +1871,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoIsGit, selectedRepoRequiresConnection, settings?.agentCmdOverrides, + settings?.agentStatusHooksEnabled, settings?.rightSidebarOpenByDefault, setRightSidebarOpen, setRightSidebarTab, @@ -2015,7 +2018,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS agent, draft: quickDraftPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) let startupPlan: ReturnType = null @@ -2033,7 +2038,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS prompt: quickPrompt, cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) if (startupPlan && quickDraftPrompt) { startupPlan.draftPrompt = quickDraftPrompt @@ -2113,6 +2120,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS selectedRepoIsGit, selectedRepoRequiresConnection, settings?.agentCmdOverrides, + settings?.agentStatusHooksEnabled, settings?.rightSidebarOpenByDefault, setRightSidebarOpen, setRightSidebarTab, diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 82cadca7f..4e1967758 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - createCompatibleRuntimeStatusResponseIfNeeded, - type RuntimeEnvironmentCallRequest -} from '@/runtime/runtime-compatibility-test-fixture' +import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' const mockSpawn = vi.fn() @@ -21,6 +18,8 @@ const mockSubscribeToPtyExit = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() const mockMarkTrusted = vi.fn() const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const CLAUDE_SCOPED_SETTINGS = + 'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json"' function expectStablePaneSpawn(): string { const spawnArgs = mockSpawn.mock.calls[0]?.[0] @@ -74,11 +73,8 @@ describe('launchAgentBackgroundSession', () => { clearRuntimeCompatibilityCacheForTests() vi.clearAllMocks() mockRuntimeEnvironmentTransportCall.mockImplementation( - (args: RuntimeEnvironmentCallRequest) => { - return ( - createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) - ) - } + (args) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? mockRuntimeEnvironmentCall(args) ) state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } state.repos = [{ id: 'repo-1', connectionId: null }] @@ -128,7 +124,7 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalledWith( expect.objectContaining({ cwd: '/repo/worktree', - command: "claude 'run the automation'", + command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`, env: expect.objectContaining({ ORCA_TAB_ID: 'tab-1', ORCA_WORKTREE_ID: 'wt-1' @@ -273,7 +269,10 @@ describe('launchAgentBackgroundSession', () => { dataSidecar('user@remote repo % ') vi.advanceTimersByTime(50) - expect(mockWrite).toHaveBeenCalledWith('pty-1', "claude 'run the automation'\r") + expect(mockWrite).toHaveBeenCalledWith( + 'pty-1', + `${CLAUDE_SCOPED_SETTINGS} 'run the automation'\r` + ) } finally { vi.useRealTimers() } @@ -307,7 +306,7 @@ describe('launchAgentBackgroundSession', () => { method: 'terminal.create', params: expect.objectContaining({ worktree: 'wt-1', - command: "claude 'run the automation'", + command: `${CLAUDE_SCOPED_SETTINGS} 'run the automation'`, env: expect.objectContaining({ ORCA_PANE_KEY: `tab-1:${leafId}`, ORCA_TAB_ID: 'tab-1', diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 03b0659d7..a35f13ae5 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -63,6 +63,7 @@ export async function launchAgentBackgroundSession( } } const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -75,7 +76,9 @@ export async function launchAgentBackgroundSession( prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -84,7 +87,9 @@ export async function launchAgentBackgroundSession( prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: !hasPrompt + allowEmptyPromptLaunch: !hasPrompt, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) } if (!startupPlan) { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 79170f70d..0a83c2b5f 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -72,6 +72,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } = args const store = useAppStore.getState() const cmdOverrides = store.settings?.agentCmdOverrides ?? {} + const useOrcaAgentStatusHooks = store.settings?.agentStatusHooksEnabled !== false const trimmedPrompt = prompt?.trim() ?? '' const hasPrompt = trimmedPrompt.length > 0 const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start' @@ -95,7 +96,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt submitPastedPrompt = true @@ -105,7 +108,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI agent, draft: trimmedPrompt, cmdOverrides, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) if (draftLaunchPlan) { startupPlan = { @@ -121,7 +126,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } @@ -131,7 +138,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) pasteDraftAfterLaunch = trimmedPrompt } else { @@ -140,7 +149,9 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI prompt: hasPrompt ? trimmedPrompt : '', cmdOverrides, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: !hasPrompt + allowEmptyPromptLaunch: !hasPrompt, + useOrcaClaudeAgentStatusSettings: useOrcaAgentStatusHooks, + useOrcaCodexAgentStatusProfile: useOrcaAgentStatusHooks }) } diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 2d85e8b84..6cc052110 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -298,7 +298,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom agent: effectiveAgent, draft: draftContent, cmdOverrides: settings?.agentCmdOverrides ?? {}, - platform: CLIENT_PLATFORM + platform: CLIENT_PLATFORM, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) if (draftLaunchPlan) { startupPlan = { @@ -315,7 +317,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom prompt: '', cmdOverrides: settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings?.agentStatusHooksEnabled !== false }) } diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 1d8d68d63..21dcf02fb 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -29,7 +29,9 @@ export function buildOnboardingFolderAgentStartup( prompt: '', cmdOverrides: settings.agentCmdOverrides ?? {}, platform: getClientPlatform(), - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: settings.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: settings.agentStatusHooksEnabled !== false }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index aecc46612..523ef2516 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { appendOrcaCodexAgentStatusProfile } from '../../../shared/codex-profile' import type { Worktree } from '../../../shared/types' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' @@ -81,7 +82,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d5d5b8c3b..2cfce6d49 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -96,7 +96,11 @@ function buildCreatedAgentReopenStartup(worktree: Worktree): prompt: '', cmdOverrides: useAppStore.getState().settings?.agentCmdOverrides ?? {}, platform: CLIENT_PLATFORM, - allowEmptyPromptLaunch: true + allowEmptyPromptLaunch: true, + useOrcaClaudeAgentStatusSettings: + useAppStore.getState().settings?.agentStatusHooksEnabled !== false, + useOrcaCodexAgentStatusProfile: + useAppStore.getState().settings?.agentStatusHooksEnabled !== false }) if (!startupPlan) { return undefined diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index 0634a8f95..e2aaea6fb 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { appendOrcaCodexAgentStatusProfile } from '../../../../shared/codex-profile' import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { createTestStore, makeWorktree } from './store-test-helpers' @@ -57,7 +58,7 @@ describe('repo slice skipped-onboarding folder startup', () => { 'folder-1::/folder', { startup: { - command: 'codex', + command: appendOrcaCodexAgentStatusProfile('codex'), telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/claude-settings.ts b/src/shared/claude-settings.ts new file mode 100644 index 000000000..fe82752be --- /dev/null +++ b/src/shared/claude-settings.ts @@ -0,0 +1,19 @@ +export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV = 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' +export const ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE = 'claude-agent-status-settings.json' + +type ClaudeSettingsShell = 'posix' | 'powershell' | 'cmd' + +export function appendOrcaClaudeAgentStatusSettings( + command: string, + shell: ClaudeSettingsShell +): string { + // Why: Claude's --settings is a per-process overlay. CLAUDE_CONFIG_DIR + // would fork auth/session state and normal external Claude launches. + if (shell === 'powershell') { + return `${command} --settings $Env:${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}` + } + if (shell === 'cmd') { + return `${command} --settings "%${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_ENV}%"` + } + return `${command} --settings "$HOME/.orca/agent-hooks/${ORCA_CLAUDE_AGENT_STATUS_SETTINGS_FILE}"` +} diff --git a/src/shared/codex-profile.ts b/src/shared/codex-profile.ts new file mode 100644 index 000000000..c3268ac06 --- /dev/null +++ b/src/shared/codex-profile.ts @@ -0,0 +1,5 @@ +export const ORCA_CODEX_AGENT_STATUS_PROFILE = 'orca-agent-status' + +export function appendOrcaCodexAgentStatusProfile(command: string): string { + return `${command} --profile-v2 ${ORCA_CODEX_AGENT_STATUS_PROFILE}` +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8d4038323..a5b62e79b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -250,6 +250,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { opencodeWorkspaceId: '', geminiCliOAuthEnabled: false, agentCmdOverrides: {}, + agentStatusHooksEnabled: true, keepComputerAwakeWhileAgentsRun: false, // Why: 'auto' runs a layout-aware probe at boot (see // src/renderer/src/lib/keyboard-layout/*) that picks 'true' for US and diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 5313a4103..f244f3bef 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -36,6 +36,79 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') }) + it('launches Codex with the Orca profile when agent status hooks are enabled', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'linux', + useOrcaCodexAgentStatusProfile: true + }) + + expect(plan?.launchCommand).toBe("codex --profile-v2 orca-agent-status 'fix it'") + }) + + it('launches Claude with the Orca settings file when agent status hooks are enabled', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'linux', + useOrcaClaudeAgentStatusSettings: true + }) + + expect(plan?.launchCommand).toBe( + 'claude --settings "$HOME/.orca/agent-hooks/claude-agent-status-settings.json" \'fix it\'' + ) + }) + + it('uses the target shell syntax for Claude settings injection', () => { + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'win32', + useOrcaClaudeAgentStatusSettings: true + })?.launchCommand + ).toBe("claude --settings $Env:ORCA_CLAUDE_AGENT_STATUS_SETTINGS 'fix it'") + + expect( + buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd', + useOrcaClaudeAgentStatusSettings: true + })?.launchCommand + ).toBe('claude --settings "%ORCA_CLAUDE_AGENT_STATUS_SETTINGS%" "fix it"') + }) + + it('leaves Claude command overrides untouched', () => { + const plan = buildAgentStartupPlan({ + agent: 'claude', + prompt: 'fix it', + cmdOverrides: { claude: 'claude --dangerously-skip-permissions' }, + platform: 'linux', + useOrcaClaudeAgentStatusSettings: true + }) + + expect(plan?.launchCommand).toBe("claude --dangerously-skip-permissions 'fix it'") + }) + + it('leaves Codex command overrides untouched', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: 'fix it', + cmdOverrides: { codex: 'codex --profile work' }, + platform: 'linux', + useOrcaCodexAgentStatusProfile: true + }) + + expect(plan?.launchCommand).toBe("codex --profile work 'fix it'") + }) + it('clears draft environment variables with the target shell syntax', () => { expect( buildAgentDraftLaunchPlan({ diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 31b8ebb50..1fc1b13dd 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,4 +1,6 @@ import { isShellProcess } from './agent-detection' +import { appendOrcaClaudeAgentStatusSettings } from './claude-settings' +import { appendOrcaCodexAgentStatusProfile } from './codex-profile' import { TUI_AGENT_CONFIG } from './tui-agent-config' import type { TuiAgent } from './types' @@ -44,6 +46,26 @@ function commandSeparator(shell: AgentStartupShell): string { return shell === 'cmd' ? ' & ' : '; ' } +function resolveBaseCommand(args: { + agent: TuiAgent + cmdOverrides: Partial> + shell: AgentStartupShell + useOrcaClaudeAgentStatusSettings?: boolean + useOrcaCodexAgentStatusProfile?: boolean +}): string { + const override = args.cmdOverrides[args.agent] + if (override) { + return override + } + const command = TUI_AGENT_CONFIG[args.agent].launchCmd + if (args.agent === 'claude' && args.useOrcaClaudeAgentStatusSettings) { + return appendOrcaClaudeAgentStatusSettings(command, args.shell) + } + return args.agent === 'codex' && args.useOrcaCodexAgentStatusProfile + ? appendOrcaCodexAgentStatusProfile(command) + : command +} + export function buildAgentStartupPlan(args: { agent: TuiAgent prompt: string @@ -51,12 +73,20 @@ export function buildAgentStartupPlan(args: { platform: NodeJS.Platform shell?: AgentStartupShell allowEmptyPromptLaunch?: boolean + useOrcaClaudeAgentStatusSettings?: boolean + useOrcaCodexAgentStatusProfile?: boolean }): AgentStartupPlan | null { const { agent, prompt, cmdOverrides, platform, allowEmptyPromptLaunch = false } = args const shell = resolveStartupShell(platform, args.shell) const trimmedPrompt = prompt.trim() const config = TUI_AGENT_CONFIG[agent] - const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const baseCommand = resolveBaseCommand({ + agent, + cmdOverrides, + shell, + useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, + useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile + }) if (!trimmedPrompt) { if (!allowEmptyPromptLaunch) { @@ -129,6 +159,8 @@ export function buildAgentDraftLaunchPlan(args: { cmdOverrides: Partial> platform: NodeJS.Platform shell?: AgentStartupShell + useOrcaClaudeAgentStatusSettings?: boolean + useOrcaCodexAgentStatusProfile?: boolean }): AgentDraftLaunchPlan | null { const { agent, draft, cmdOverrides, platform } = args const shell = resolveStartupShell(platform, args.shell) @@ -137,7 +169,13 @@ export function buildAgentDraftLaunchPlan(args: { if (!trimmed) { return null } - const baseCommand = cmdOverrides[agent] ?? config.launchCmd + const baseCommand = resolveBaseCommand({ + agent, + cmdOverrides, + shell, + useOrcaClaudeAgentStatusSettings: args.useOrcaClaudeAgentStatusSettings, + useOrcaCodexAgentStatusProfile: args.useOrcaCodexAgentStatusProfile + }) if (config.draftPromptFlag) { const quoted = quoteStartupArg(trimmed, shell) return { diff --git a/src/shared/types.ts b/src/shared/types.ts index 49e43ed3c..3647d2d3b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1770,6 +1770,9 @@ export type GlobalSettings = { geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> + /** Why: disabling must persist so startup does not reinstall global agent + * hook entries right after the user removes them from Settings or CLI. */ + agentStatusHooksEnabled: boolean /** When true, Orca requests local awake assertions while hook-reported agents are working. */ keepComputerAwakeWhileAgentsRun: boolean /** Why: macOS terminals must choose between letting Option compose layout