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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1627 additions and 275 deletions

View File

@ -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/**',

View File

@ -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",

View File

@ -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'
)
}
}
},

View File

@ -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]))

View File

@ -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<string, string | boolean>
@ -44,6 +45,7 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS,
COMPUTER_HANDLERS,
AGENT_HOOK_HANDLERS,
ENVIRONMENT_HANDLERS
]
for (const group of groups) {

View File

@ -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<string, unknown> {
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<boolean> {
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<TResult>(result: TResult): RuntimeRpcSuccess<TResult> {
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<AgentHookCommandResult> {
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<string, CommandHandler> = {
'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)
}
}

View File

@ -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<void> {

View File

@ -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']
}
]

View File

@ -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
]

View File

@ -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<GlobalSettings, 'agentStatusHooksEnabled'> | 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()
}

View File

@ -24,9 +24,12 @@ type FakeFs = {
failRenameTo: Set<string>
}
function createFakeSftp(): { sftp: SFTPWrapper; fs: FakeFs } {
function createFakeSftp(initialFiles: Record<string, string> = {}): {
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<string, unknown>
}
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')

View File

@ -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)],

View File

@ -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<string, string>
dirs: Set<string>
@ -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'
)
})
})

View File

@ -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<string, string> {
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<AgentHookInstallStatus> {
// 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()
}
}

View File

@ -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
}
}

View File

@ -32,6 +32,7 @@ vi.mock('node:os', async () => {
function createSettings(overrides: Partial<GlobalSettings> = {}): 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> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
appFontFamily
appFontFamily,
agentStatusHooksEnabled
}
}

View File

@ -25,6 +25,7 @@ vi.mock('node:os', async () => {
function createSettings(overrides: Partial<GlobalSettings> = {}): 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> = {}): GlobalSettings
terminalWindowsPowerShellImplementation: 'powershell.exe',
enableGitHubAttribution: true,
...overrides,
appFontFamily
appFontFamily,
agentStatusHooksEnabled
}
}

View File

@ -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`)

View File

@ -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)
})
})

View File

@ -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<string, CodexHookTrustState>
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<AgentHookInstallStatus> {
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 —

View File

@ -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, {

View File

@ -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',

View File

@ -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<string, string>,
processEnvOverrides?: Record<string, string | undefined>,
getSelectedCodexHomePath?: () => string | null,
getSettings?: () => { enableGitHubAttribution: boolean }
getSettings?: () => { enableGitHubAttribution?: boolean; agentStatusHooksEnabled?: boolean }
): Promise<Record<string, string>> {
const savedEnv: Record<string, string | undefined> = {}
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()

View File

@ -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, string>): 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<string, string>,
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

View File

@ -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()
}

View File

@ -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
})

View File

@ -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<GlobalSettings>,
| '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

View File

@ -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()

View File

@ -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<void> {
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<void> {
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)

View File

@ -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}.`)

View File

@ -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',

View File

@ -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)
})
})

View File

@ -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>
)
}

View File

@ -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'
]

View File

@ -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(),

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,

View File

@ -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',

View File

@ -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) {

View File

@ -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
})
}

View File

@ -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
})
}

View File

@ -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

View File

@ -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',

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

View File

@ -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',

View File

@ -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}"`
}

View File

@ -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}`
}

View File

@ -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

View File

@ -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({

View File

@ -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<Record<TuiAgent, string>>
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<Record<TuiAgent, string>>
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 {

View File

@ -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<Record<TuiAgent, string>>
/** 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