refactor(agents): remove dead hook IPC and derive shared agent defaults (#16089)

* refactor(agent-hooks): drop the unused per-agent hook status IPC surface

No renderer, CLI, or mobile caller invoked window.api.agentHooks.*Status; main
already reads install status through MANAGED_AGENT_HOOK_STATUS_READERS. The
14 handlers had also drifted (kimiStatus existed in main/preload but not in
AgentHooksApi or the web stub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tui-agent-config): default launchCmd and expectedProcess to detectCmd

32 of 36 entries repeated the binary name three times. Entries are now
authored in a source form where both default to detectCmd and resolved once
at module load, so TUI_AGENT_CONFIG keeps its exact shape for consumers
(verified equal to the previous table).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mobile): derive the agent order, labels, and picker from src/shared

The mobile mirror (and its regex-over-desktop-source parity test) predates
mobile importing runtime values from src/shared, which it now does in a dozen
modules. Only the favicon-domain map stays mobile-local because desktop's lives
in the renderer catalog next to bundled ?url imports. The parity test now
imports the real registries and also checks label parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): align preload surface after hook IPC removal

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
trs-80
2026-08-30 19:02:14 -07:00
committed by GitHub
co-authored by Claude Fable 5 Brennan Benson
parent fe82569b97
commit a651e81843
12 changed files with 93 additions and 592 deletions
+12 -31
View File
@@ -1,43 +1,24 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { TUI_AGENT_CONFIG } from '../../../src/shared/tui-agent-config'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import { TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/shared/tui-agent-selection'
import { MOBILE_AGENT_CATALOG } from './mobile-agent-catalog'
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from './mobile-tui-agents'
const currentDir = import.meta.dirname
function readDesktopSharedFile(relativePath: string): string {
return readFileSync(resolve(currentDir, '../../../src/shared', relativePath), 'utf8')
}
function parseDesktopAutoPickOrder(): string[] {
const source = readDesktopSharedFile('tui-agent-selection.ts')
const match = source.match(/TUI_AGENT_AUTO_PICK_ORDER = \[([\s\S]*?)\] as const/)
expect(match).not.toBeNull()
return Array.from(match?.[1].matchAll(/'([^']+)'/g) ?? [], (entry) => entry[1])
}
function parseDesktopConfiguredAgents(): string[] {
const source = readDesktopSharedFile('tui-agent-config.ts')
const match = source.match(/TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {([\s\S]*?)^}/m)
expect(match).not.toBeNull()
return Array.from(
match?.[1].matchAll(/^ (?:'([^']+)'|([a-z][a-z0-9-]*)): {/gm) ?? [],
(entry) => entry[1] ?? entry[2]
)
}
describe('mobile agent catalog', () => {
it('stays in the same order as desktop auto-pick and covers every configured TUI agent', () => {
const desktopAutoPickOrder = parseDesktopAutoPickOrder()
expect(MOBILE_TUI_AGENT_AUTO_PICK_ORDER).toEqual(desktopAutoPickOrder)
expect(MOBILE_AGENT_CATALOG.map((agent) => agent.id)).toEqual(desktopAutoPickOrder)
it('follows desktop auto-pick order and covers every configured TUI agent', () => {
expect(MOBILE_AGENT_CATALOG.map((agent) => agent.id)).toEqual([...TUI_AGENT_AUTO_PICK_ORDER])
expect(new Set(MOBILE_AGENT_CATALOG.map((agent) => agent.id))).toEqual(
new Set(parseDesktopConfiguredAgents())
new Set(Object.keys(TUI_AGENT_CONFIG))
)
})
it('labels every agent with the desktop display name', () => {
for (const entry of MOBILE_AGENT_CATALOG) {
expect(entry.label).toBe(TUI_AGENT_DISPLAY_NAMES[entry.id])
}
})
it('uses the bundled Claude icon path for Claude Agent Teams', () => {
expect(MOBILE_AGENT_CATALOG.find((agent) => agent.id === 'claude-agent-teams')).toEqual(
expect.not.objectContaining({ faviconDomain: expect.any(String) })
+26 -117
View File
@@ -1,85 +1,17 @@
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { isTuiAgent } from '../../../src/shared/tui-agent-config'
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import {
TUI_AGENT_AUTO_PICK_ORDER,
isTuiAgentEnabled,
normalizeDisabledTuiAgents,
pickTuiAgent
} from '../../../src/shared/tui-agent-selection'
// Why: mobile tests run from the mobile package only, so runtime imports of
// desktop shared modules can break Vitest transforms in CI. Keep this list
// mirrored with src/shared/tui-agent-selection.ts and assert parity in tests.
export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'claude',
'claude-agent-teams',
'openclaude',
'codex',
'grok',
'copilot',
'opencode',
'mimo-code',
'ante',
'trae',
'pi',
'omp',
'prime-agent',
'gemini',
'antigravity',
'aider',
'goose',
'amp',
'kilo',
'kiro',
'crush',
'aug',
'autohand',
'cline',
'codebuff',
'command-code',
'continue',
'cursor',
'droid',
'kimi',
'mistral-vibe',
'qwen-code',
'rovo',
'hermes',
'devin',
'openclaw'
] as const satisfies readonly TuiAgent[]
export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
claude: 'Claude',
'claude-agent-teams': 'Claude Agent Teams',
openclaude: 'OpenClaude',
codex: 'Codex',
grok: 'Grok',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
'mimo-code': 'MiMo Code',
ante: 'Ante',
trae: 'Trae',
pi: 'Pi',
omp: 'OMP',
'prime-agent': 'Prime Agent',
gemini: 'Gemini',
antigravity: 'Antigravity',
aider: 'Aider',
goose: 'Goose',
amp: 'Amp',
kilo: 'Kilocode',
kiro: 'Kiro',
crush: 'Charm',
aug: 'Auggie',
autohand: 'Autohand Code',
cline: 'Cline',
codebuff: 'Codebuff',
'command-code': 'Command Code',
continue: 'Continue',
cursor: 'Cursor',
droid: 'Droid',
kimi: 'Kimi',
'mistral-vibe': 'Mistral Vibe',
'qwen-code': 'Qwen Code',
rovo: 'Rovo Dev',
hermes: 'Hermes',
devin: 'Devin',
openclaw: 'OpenClaw'
}
// Why: one agent registry. Mobile keeps its own names for the favicon domains only, because
// desktop's live in the renderer catalog next to bundled `?url` icon imports Metro can't load.
export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = TUI_AGENT_AUTO_PICK_ORDER
export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = TUI_AGENT_DISPLAY_NAMES
export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>> = {
openclaude: 'openclaude.gitlawb.com',
@@ -115,33 +47,15 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
openclaw: 'openclaw.ai'
}
export function isMobileTuiAgent(value: unknown): value is TuiAgent {
return MOBILE_TUI_AGENT_AUTO_PICK_ORDER.includes(value as TuiAgent)
}
export const isMobileTuiAgent: (value: unknown) => value is TuiAgent = isTuiAgent
function normalizeDisabledMobileTuiAgents(value: unknown): TuiAgent[] {
if (!Array.isArray(value)) {
return []
}
const seen = new Set<TuiAgent>()
for (const item of value) {
if (isMobileTuiAgent(item)) {
seen.add(item)
}
}
return [...seen]
// Why: mobile passes raw persisted settings through; the shared helpers already discard non-arrays.
function asDisabledList(disabled: unknown): Iterable<unknown> | null {
return Array.isArray(disabled) ? disabled : null
}
export function isMobileTuiAgentEnabled(agent: TuiAgent, disabled?: unknown): boolean {
return !normalizeDisabledMobileTuiAgents(disabled).includes(agent)
}
export function filterEnabledMobileTuiAgents<T extends TuiAgent>(
agents: Iterable<T>,
disabled?: unknown
): T[] {
const disabledSet = new Set(normalizeDisabledMobileTuiAgents(disabled))
return [...agents].filter((agent) => !disabledSet.has(agent))
return isTuiAgentEnabled(agent, asDisabledList(disabled))
}
export function pickMobileTuiAgent(
@@ -149,18 +63,13 @@ export function pickMobileTuiAgent(
detected: Iterable<TuiAgent>,
disabled?: unknown
): TuiAgent | null {
if (preferred === 'blank') {
return null
}
const disabledSet = new Set(normalizeDisabledMobileTuiAgents(disabled))
const detectedSet = detected instanceof Set ? detected : new Set(detected)
if (preferred && detectedSet.has(preferred) && !disabledSet.has(preferred)) {
return preferred
}
for (const agent of MOBILE_TUI_AGENT_AUTO_PICK_ORDER) {
if (detectedSet.has(agent) && !disabledSet.has(agent)) {
return agent
}
}
return null
return pickTuiAgent(preferred, detected, asDisabledList(disabled))
}
export function filterEnabledMobileTuiAgents<T extends TuiAgent>(
agents: Iterable<T>,
disabled?: unknown
): T[] {
const disabledSet = new Set(normalizeDisabledTuiAgents(disabled))
return [...agents].filter((agent) => !disabledSet.has(agent))
}
-66
View File
@@ -208,72 +208,6 @@ describe('agentStatus:getSnapshot IPC', () => {
})
})
describe('agentHooks:antigravityStatus IPC', () => {
it('returns Antigravity hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:antigravityStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'antigravity', state: 'absent' })
})
})
describe('agentHooks:ampStatus IPC', () => {
it('returns Amp hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:ampStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'amp', state: 'absent' })
})
})
describe('agentHooks:openClaudeStatus IPC', () => {
it('returns OpenClaude hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:openClaudeStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'openclaude', state: 'absent' })
})
})
describe('agentHooks:commandCodeStatus IPC', () => {
it('returns Command Code hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:commandCodeStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'command-code', state: 'absent' })
})
})
describe('agentHooks:devinStatus IPC', () => {
it('returns Devin hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:devinStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'devin', state: 'absent' })
})
})
describe('agentHooks:kimiStatus IPC', () => {
it('returns Kimi hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:kimiStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'kimi', state: 'absent' })
})
})
describe('agentStatus:inferInterrupt IPC', () => {
it('forwards valid inference requests to the hook server', async () => {
inferInterrupt.mockReturnValue(true)
-217
View File
@@ -1,5 +1,4 @@
import { ipcMain } from 'electron'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type {
AgentStatusIpcPayload,
MigrationUnsupportedPtyEntry
@@ -7,21 +6,7 @@ import type {
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
import type { AgentQuestionAnsweredInferenceRequest } from '../../shared/agent-question-answered-intent'
import { agentHookServer } from '../agent-hooks/server'
import { ampHookService } from '../amp/hook-service'
import { getMigrationUnsupportedPtySnapshot } 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 { commandCodeHookService } from '../command-code/hook-service'
import { grokHookService } from '../grok/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { devinHookService } from '../devin/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc'
import { registerAgentStatusRowTeardownIpcHandlers } from './agent-status-row-teardown-ipc'
import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership'
@@ -48,20 +33,6 @@ export function registerAgentHookHandlers(
// recreates the main window). Today the module-level `registered` guard in
// register-core-handlers.ts prevents re-entry, but decoupling from that guard
// future-proofs this file.
ipcMain.removeHandler('agentHooks:claudeStatus')
ipcMain.removeHandler('agentHooks:openClaudeStatus')
ipcMain.removeHandler('agentHooks:codexStatus')
ipcMain.removeHandler('agentHooks:geminiStatus')
ipcMain.removeHandler('agentHooks:antigravityStatus')
ipcMain.removeHandler('agentHooks:ampStatus')
ipcMain.removeHandler('agentHooks:cursorStatus')
ipcMain.removeHandler('agentHooks:droidStatus')
ipcMain.removeHandler('agentHooks:commandCodeStatus')
ipcMain.removeHandler('agentHooks:grokStatus')
ipcMain.removeHandler('agentHooks:copilotStatus')
ipcMain.removeHandler('agentHooks:hermesStatus')
ipcMain.removeHandler('agentHooks:devinStatus')
ipcMain.removeHandler('agentHooks:kimiStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
@@ -98,192 +69,4 @@ export function registerAgentHookHandlers(
'agentStatus:getMigrationUnsupportedSnapshot',
(): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot()
)
// Why: errors from getStatus() (fs permission denied, homedir resolution
// failure, etc.) must be reported inline via state:'error' so the sidebar can
// render a coherent per-agent error row. Letting the exception propagate out
// of the IPC handler surfaces as an unhandled renderer-side rejection, which
// defeats the AgentHookInstallStatus contract the UI relies on.
ipcMain.handle('agentHooks:claudeStatus', (): AgentHookInstallStatus => {
try {
return claudeHookService.getStatus()
} catch (err) {
return {
agent: 'claude',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:openClaudeStatus', (): AgentHookInstallStatus => {
try {
return openClaudeHookService.getStatus()
} catch (err) {
return {
agent: 'openclaude',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => {
try {
return codexHookService.getStatus()
} catch (err) {
return {
agent: 'codex',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:geminiStatus', (): AgentHookInstallStatus => {
try {
return geminiHookService.getStatus()
} catch (err) {
return {
agent: 'gemini',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:antigravityStatus', (): AgentHookInstallStatus => {
try {
return antigravityHookService.getStatus()
} catch (err) {
return {
agent: 'antigravity',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:ampStatus', (): AgentHookInstallStatus => {
try {
return ampHookService.getStatus()
} catch (err) {
return {
agent: 'amp',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:cursorStatus', (): AgentHookInstallStatus => {
try {
return cursorHookService.getStatus()
} catch (err) {
return {
agent: 'cursor',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:droidStatus', (): AgentHookInstallStatus => {
try {
return droidHookService.getStatus()
} catch (err) {
return {
agent: 'droid',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:commandCodeStatus', (): AgentHookInstallStatus => {
try {
return commandCodeHookService.getStatus()
} catch (err) {
return {
agent: 'command-code',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:grokStatus', (): AgentHookInstallStatus => {
try {
return grokHookService.getStatus()
} catch (err) {
return {
agent: 'grok',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:copilotStatus', (): AgentHookInstallStatus => {
try {
return copilotHookService.getStatus()
} catch (err) {
return {
agent: 'copilot',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:hermesStatus', (): AgentHookInstallStatus => {
try {
return hermesHookService.getStatus()
} catch (err) {
return {
agent: 'hermes',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:devinStatus', (): AgentHookInstallStatus => {
try {
return devinHookService.getStatus()
} catch (err) {
return {
agent: 'devin',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
ipcMain.handle('agentHooks:kimiStatus', (): AgentHookInstallStatus => {
try {
return kimiHookService.getStatus()
} catch (err) {
return {
agent: 'kimi',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
}
+1 -2
View File
@@ -6,7 +6,7 @@ import type {
GrokAccountsApi,
MinimaxCredentialsApi
} from './api/agent-account-api'
import type { AgentHooksApi, HooksApi } from './api/agent-hook-api'
import type { HooksApi } from './api/agent-hook-api'
import type { SkillsApi } from './api/agent-skill-api'
import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-status-api'
import type {
@@ -104,7 +104,6 @@ export type PreloadApi = {
claudeAccounts: ClaudeAccountsApi
cli: CliApi
codexConfigSync: CodexConfigSyncApi
agentHooks: AgentHooksApi
agentTrust: AgentTrustApi
preflight: PreflightApi
notifications: NotificationsApi
-17
View File
@@ -2,7 +2,6 @@ import type { OrcaHooks } from '../../shared/orca-yaml-hook-types'
import type { WorktreeSetupLaunch } from '../../shared/worktree/launch-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { SetupScriptImportCandidate } from '../../shared/setup-script-imports'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
export type HooksApi = {
check: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{
@@ -34,19 +33,3 @@ export type HooksApi = {
hostId?: ExecutionHostId
}) => Promise<void>
}
export type AgentHooksApi = {
claudeStatus: () => Promise<AgentHookInstallStatus>
openClaudeStatus: () => Promise<AgentHookInstallStatus>
codexStatus: () => Promise<AgentHookInstallStatus>
geminiStatus: () => Promise<AgentHookInstallStatus>
antigravityStatus: () => Promise<AgentHookInstallStatus>
ampStatus: () => Promise<AgentHookInstallStatus>
cursorStatus: () => Promise<AgentHookInstallStatus>
droidStatus: () => Promise<AgentHookInstallStatus>
commandCodeStatus: () => Promise<AgentHookInstallStatus>
grokStatus: () => Promise<AgentHookInstallStatus>
copilotStatus: () => Promise<AgentHookInstallStatus>
hermesStatus: () => Promise<AgentHookInstallStatus>
devinStatus: () => Promise<AgentHookInstallStatus>
}
-29
View File
@@ -34,7 +34,6 @@ import type {
} from '../shared/terminal-preview'
import type { AgentSessionPtyWriteRefusal } from '../shared/agent-session-pty-write-admission'
import type { CliInstallStatus } from '../shared/cli-install-types'
import type { AgentHookInstallStatus } from '../shared/agent-hook-types'
import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types'
import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry'
import type { TerminalTabCreateReply } from '../shared/terminal-reveal-identity'
@@ -2314,34 +2313,6 @@ const api = {
codexConfigSync: {
status: (): Promise<CodexConfigSyncStatus> => ipcRenderer.invoke('codexConfigSync:status')
},
agentHooks: {
claudeStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:claudeStatus'),
openClaudeStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:openClaudeStatus'),
codexStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:codexStatus'),
geminiStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:geminiStatus'),
antigravityStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:antigravityStatus'),
ampStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:ampStatus'),
cursorStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:cursorStatus'),
droidStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:droidStatus'),
commandCodeStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:commandCodeStatus'),
grokStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:grokStatus'),
devinStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:devinStatus'),
copilotStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:copilotStatus'),
hermesStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:hermesStatus'),
kimiStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:kimiStatus')
},
agentTrust: {
markTrusted: (args: {
preset: 'cursor' | 'copilot' | 'codex'
@@ -1,42 +0,0 @@
import type { PreloadApi } from '../../../../preload/api-types'
export function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
const status = (
agent:
| 'claude'
| 'openclaude'
| 'codex'
| 'gemini'
| 'antigravity'
| 'amp'
| 'cursor'
| 'droid'
| 'command-code'
| 'grok'
| 'copilot'
| 'hermes'
| 'devin'
) =>
Promise.resolve({
agent,
state: 'not_installed',
configPath: '',
managedHooksPresent: false,
detail: 'Agent hook status is only available on the Orca server.'
} as const)
return {
claudeStatus: () => status('claude'),
openClaudeStatus: () => status('openclaude'),
codexStatus: () => status('codex'),
geminiStatus: () => status('gemini'),
antigravityStatus: () => status('antigravity'),
ampStatus: () => status('amp'),
cursorStatus: () => status('cursor'),
droidStatus: () => status('droid'),
commandCodeStatus: () => status('command-code'),
grokStatus: () => status('grok'),
copilotStatus: () => status('copilot'),
hermesStatus: () => status('hermes'),
devinStatus: () => status('devin')
}
}
@@ -58,7 +58,6 @@ describe('web preload API composition', () => {
'codexAccounts',
'claudeAccounts',
'cli',
'agentHooks',
'macosTccPrompts',
'codexConfigSync',
'developerPermissions',
-2
View File
@@ -6,7 +6,6 @@ import {
createGrokAccountsApi,
createMiniMaxCredentialsApi
} from './preload-api/web-agent-accounts-api'
import { createAgentHooksApi } from './preload-api/web-agent-hooks-api'
import { createWebAgentStatusApi } from './preload-api/web-agent-status-api'
import { createWebAiVaultApi } from './preload-api/web-ai-vault-api'
import { createWebAppApi } from './preload-api/web-app-api'
@@ -111,7 +110,6 @@ function createWebPreloadApi(): Partial<PreloadApi> {
codexAccounts: createAccountsApi(),
claudeAccounts: createAccountsApi(),
cli: createCliApi(),
agentHooks: createAgentHooksApi(),
macosTccPrompts: createMacosTccPromptsApi(),
codexConfigSync: {
status: () =>
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { TuiAgent } from './tui-agent'
describe('TUI_AGENT_CONFIG', () => {
it('resolves launchCmd and expectedProcess for every agent', () => {
for (const [agent, config] of Object.entries(TUI_AGENT_CONFIG)) {
expect(config.launchCmd, agent).toBeTruthy()
expect(config.expectedProcess, agent).toBeTruthy()
}
})
it('defaults launchCmd and expectedProcess to detectCmd', () => {
expect(TUI_AGENT_CONFIG.codex).toMatchObject({
detectCmd: 'codex',
launchCmd: 'codex',
expectedProcess: 'codex'
})
})
it('keeps explicit overrides where the launch line or process differs from the binary', () => {
const overrides: Partial<Record<TuiAgent, Partial<(typeof TUI_AGENT_CONFIG)[TuiAgent]>>> = {
'claude-agent-teams': { launchCmd: 'orca claude-teams', expectedProcess: 'claude' },
kiro: { launchCmd: 'kiro-cli chat --tui', expectedProcess: 'kiro-cli' },
'command-code': { launchCmd: 'command-code --trust' },
hermes: { launchCmd: 'hermes --tui' }
}
for (const [agent, expected] of Object.entries(overrides)) {
expect(TUI_AGENT_CONFIG[agent as TuiAgent]).toMatchObject(expected)
}
})
})
+22 -68
View File
@@ -52,11 +52,23 @@ export type TuiAgentConfig = {
ctrlEnterEncoding?: 'csi-u'
}
export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
/** Authoring form: `launchCmd` and `expectedProcess` default to `detectCmd` (true for most agents). */
type TuiAgentConfigSource = Omit<TuiAgentConfig, 'launchCmd' | 'expectedProcess'> & {
launchCmd?: string
expectedProcess?: string
}
function resolveTuiAgentConfig(source: TuiAgentConfigSource): TuiAgentConfig {
return {
...source,
launchCmd: source.launchCmd ?? source.detectCmd,
expectedProcess: source.expectedProcess ?? source.detectCmd
}
}
const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
claude: {
detectCmd: 'claude',
launchCmd: 'claude',
expectedProcess: 'claude',
promptInjectionMode: 'argv',
// Why: `claude --prefill <text>` seeds the input without submitting, avoiding the paste-after-ready race (PR https://github.com/stablyai/orca/pull/926).
draftPromptFlag: '--prefill'
@@ -79,15 +91,11 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
openclaude: {
detectCmd: 'openclaude',
launchCmd: 'openclaude',
expectedProcess: 'openclaude',
promptInjectionMode: 'argv',
draftPromptFlag: '--prefill'
},
codex: {
detectCmd: 'codex',
launchCmd: 'codex',
expectedProcess: 'codex',
promptInjectionMode: 'argv',
windowsInputRecordPasteNewline: 'alt-enter',
preflightTrust: 'codex',
@@ -97,14 +105,10 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
autohand: {
detectCmd: 'autohand',
launchCmd: 'autohand',
expectedProcess: 'autohand',
promptInjectionMode: 'stdin-after-start'
},
ante: {
detectCmd: 'ante',
launchCmd: 'ante',
expectedProcess: 'ante',
// Why: `ante --prompt` is headless (runs once and exits), so launch the bare TUI and inject after startup.
promptInjectionMode: 'stdin-after-start'
},
@@ -112,8 +116,6 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
// Why: the unrelated open-source bytedance/trae-agent also installs a `trae-cli`
// binary, so detect TRAE CN's CLI on `traecli`, an alias only TRAE CN ships.
detectCmd: 'traecli',
launchCmd: 'traecli',
expectedProcess: 'traecli',
// Why: `traecli [prompt]` takes the task as a positional argv, same as Claude/Codex.
promptInjectionMode: 'argv',
// Why: separator so prompts starting with `help`/`config`/`-…` aren't parsed as a
@@ -122,24 +124,18 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
opencode: {
detectCmd: 'opencode',
launchCmd: 'opencode',
expectedProcess: 'opencode',
promptInjectionMode: 'flag-prompt',
// Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands.
draftPasteReadySignal: 'render-cursor-after-bracketed-paste'
},
'mimo-code': {
detectCmd: 'mimo',
launchCmd: 'mimo',
expectedProcess: 'mimo',
promptInjectionMode: 'flag-prompt',
// Why: mirrors opencode's cursor-gated signal by parity; mimo's startup stream isn't separately validated.
draftPasteReadySignal: 'render-cursor-after-bracketed-paste'
},
pi: {
detectCmd: 'pi',
launchCmd: 'pi',
expectedProcess: 'pi',
promptInjectionMode: 'argv',
// Why: pi has no `--prefill` and paste-after-ready races its long startup; the orca-prefill extension seeds this env var instead.
draftPromptEnvVar: 'ORCA_PI_PREFILL',
@@ -148,8 +144,6 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
omp: {
detectCmd: 'omp',
launchCmd: 'omp',
expectedProcess: 'omp',
promptInjectionMode: 'argv',
draftPromptEnvVar: 'ORCA_OMP_PREFILL',
// Why: OMP wraps Pi's TUI, so the bytes land in a Pi reader that decodes CSI-u (see pi above).
@@ -157,8 +151,6 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
'prime-agent': {
detectCmd: 'prime-agent',
launchCmd: 'prime-agent',
expectedProcess: 'prime-agent',
// Why: `prime-agent [options] [@files...] [message...]` takes the task as positional argv.
promptInjectionMode: 'argv',
// Why: separator so prompts starting with `help`/`agents`/`-…` aren't parsed as a
@@ -169,38 +161,26 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
gemini: {
detectCmd: 'gemini',
launchCmd: 'gemini',
expectedProcess: 'gemini',
promptInjectionMode: 'flag-prompt-interactive'
},
antigravity: {
detectCmd: 'agy',
launchCmd: 'agy',
expectedProcess: 'agy',
promptInjectionMode: 'flag-prompt-interactive'
},
aider: {
detectCmd: 'aider',
launchCmd: 'aider',
expectedProcess: 'aider',
promptInjectionMode: 'stdin-after-start'
},
goose: {
detectCmd: 'goose',
launchCmd: 'goose',
expectedProcess: 'goose',
promptInjectionMode: 'stdin-after-start'
},
amp: {
detectCmd: 'amp',
launchCmd: 'amp',
expectedProcess: 'amp',
promptInjectionMode: 'stdin-after-start'
},
kilo: {
detectCmd: 'kilo',
launchCmd: 'kilo',
expectedProcess: 'kilo',
promptInjectionMode: 'stdin-after-start'
},
kiro: {
@@ -208,32 +188,23 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
detectCmd: 'kiro-cli',
// Why: trust flags like --trust-all-tools attach to Kiro's `chat` subcommand, not top-level kiro-cli.
launchCmd: 'kiro-cli chat --tui',
expectedProcess: 'kiro-cli',
promptInjectionMode: 'stdin-after-start'
},
crush: {
detectCmd: 'crush',
launchCmd: 'crush',
expectedProcess: 'crush',
promptInjectionMode: 'stdin-after-start'
},
aug: {
// Why: @augmentcode/auggie installs a binary named `auggie`, not `aug`; keep id 'aug' for stored prefs.
detectCmd: 'auggie',
launchCmd: 'auggie',
expectedProcess: 'auggie',
promptInjectionMode: 'stdin-after-start'
},
cline: {
detectCmd: 'cline',
launchCmd: 'cline',
expectedProcess: 'cline',
promptInjectionMode: 'stdin-after-start'
},
codebuff: {
detectCmd: 'codebuff',
launchCmd: 'codebuff',
expectedProcess: 'codebuff',
promptInjectionMode: 'stdin-after-start'
},
'command-code': {
@@ -241,28 +212,21 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
detectCmd: 'command-code',
// Why: `--trust` skips the first-run trust prompt so it doesn't consume the task text.
launchCmd: 'command-code --trust',
expectedProcess: 'command-code',
promptInjectionMode: 'argv'
},
continue: {
// Why: Continue's CLI binary is `cn`; `continue` is a bash/zsh builtin and would resolve to the shell keyword.
detectCmd: 'cn',
launchCmd: 'cn',
expectedProcess: 'cn',
promptInjectionMode: 'stdin-after-start'
},
cursor: {
detectCmd: 'cursor-agent',
launchCmd: 'cursor-agent',
expectedProcess: 'cursor-agent',
promptInjectionMode: 'argv',
// Why: first-launch trust menu swallows the bracketed paste; pre-write the .workspace-trusted marker so it skips (agent-trust-presets.ts).
preflightTrust: 'cursor'
},
droid: {
detectCmd: 'droid',
launchCmd: 'droid',
expectedProcess: 'droid',
promptInjectionMode: 'argv',
// Why: Droid decodes CSI-u on Windows; the legacy Esc+CR fallback reads as Enter and submits instead of newline.
windowsShiftEnterEncoding: 'csi-u',
@@ -270,49 +234,36 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
kimi: {
detectCmd: 'kimi',
launchCmd: 'kimi',
expectedProcess: 'kimi',
promptInjectionMode: 'stdin-after-start'
},
'mistral-vibe': {
// Why: installer exposes binary `vibe` though the package is mistral-vibe; keep old name as alias for wrapped installs.
detectCmd: 'vibe',
detectCmdAliases: ['mistral-vibe'],
launchCmd: 'vibe',
expectedProcess: 'vibe',
promptInjectionMode: 'stdin-after-start'
},
'qwen-code': {
// Why: package is qwen-code but its installed CLI binary on PATH is `qwen`.
detectCmd: 'qwen',
launchCmd: 'qwen',
expectedProcess: 'qwen',
promptInjectionMode: 'stdin-after-start'
},
rovo: {
detectCmd: 'rovo',
launchCmd: 'rovo',
expectedProcess: 'rovo',
promptInjectionMode: 'stdin-after-start'
},
hermes: {
detectCmd: 'hermes',
// Why: bare `hermes` opens the classic REPL; `--tui` starts the full-screen agent UI Orca hosts.
launchCmd: 'hermes --tui',
expectedProcess: 'hermes',
// Why: Hermes delivers the prompt via its startup-query contract, submitting only after the composer is ready.
promptInjectionMode: 'hermes-query'
},
openclaw: {
detectCmd: 'openclaw',
launchCmd: 'openclaw',
expectedProcess: 'openclaw',
promptInjectionMode: 'stdin-after-start'
},
copilot: {
detectCmd: 'copilot',
launchCmd: 'copilot',
expectedProcess: 'copilot',
// Why: `--prompt` exits on completion (kills the hosted session); `-i/--interactive` keeps it interactive.
promptInjectionMode: 'flag-interactive',
// Why: first-launch trust menu swallows the bracketed paste; pre-write trust so it skips (see agent-trust-presets.ts).
@@ -320,8 +271,6 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
grok: {
detectCmd: 'grok',
launchCmd: 'grok',
expectedProcess: 'grok',
// Why: argv (grok takes a positional prompt) so multi-line/special-char text isn't mangled as raw PTY keystrokes.
promptInjectionMode: 'argv',
// Why: separator so prompts like `help`/`--version` aren't parsed as Grok CLI syntax.
@@ -334,13 +283,18 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
},
devin: {
detectCmd: 'devin',
launchCmd: 'devin',
expectedProcess: 'devin',
// Why: `devin -- <prompt>` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt.
promptInjectionMode: 'stdin-after-start'
}
}
export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = Object.fromEntries(
Object.entries(TUI_AGENT_CONFIG_SOURCE).map(([agent, source]) => [
agent,
resolveTuiAgentConfig(source)
])
) as Record<TuiAgent, TuiAgentConfig>
export function isTuiAgent(value: unknown): value is TuiAgent {
return typeof value === 'string' && Object.hasOwn(TUI_AGENT_CONFIG, value)
}