Restore agent hook opt-out controls (#2778)

This commit is contained in:
Neil
2026-05-25 10:37:22 -07:00
committed by GitHub
parent aaa6ade586
commit d4703bd1ab
50 changed files with 1627 additions and 275 deletions
@@ -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}.`)
@@ -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',
@@ -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)
})
})
@@ -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
</div>
</section>
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
{detectedAgents.length > 0 && (
@@ -400,3 +403,25 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
</div>
)
}
export function AgentStatusHooksSetting({
settings,
updateSettings
}: AgentsPaneProps): React.JSX.Element {
const enabled = settings.agentStatusHooksEnabled !== false
return (
<section className="space-y-3">
<SettingsSwitchRow
label={AGENT_STATUS_HOOKS_TITLE}
description={AGENT_STATUS_HOOKS_DESCRIPTION}
checked={enabled}
onChange={() =>
updateSettings({
agentStatusHooksEnabled: !enabled
})
}
ariaLabel={AGENT_STATUS_HOOKS_TITLE}
/>
</section>
)
}
@@ -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'
]
@@ -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(),
+11 -3
View File
@@ -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<typeof buildAgentStartupPlan> = 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,
@@ -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',
@@ -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) {
@@ -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
})
}
@@ -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
})
}
@@ -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
@@ -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',
+5 -1
View File
@@ -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
@@ -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',