Add Grok agent support

This commit is contained in:
Neil 2026-05-15 13:52:39 -07:00 committed by GitHub
parent c90b9f72ef
commit e7f3bb6034
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 876 additions and 67 deletions

View File

@ -14,7 +14,7 @@
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Available for <strong>macOS, Windows, and Linux</strong>.
</p>
@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
@ -60,7 +61,7 @@ Orca supports any CLI agent (*not just this list*).
## Features
- **No login required** — Bring your own Claude Code or Codex subscription.
- **No login required** — Bring your own Claude Code, Codex, or Grok subscription.
- **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly.
- **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance.
- **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.

View File

@ -14,7 +14,7 @@
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Ejecuta Claude Code, Codex, Grok u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
</p>
@ -33,6 +33,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;

View File

@ -33,6 +33,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;

View File

@ -14,7 +14,7 @@
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
</p>
@ -33,6 +33,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;

View File

@ -33,6 +33,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;

View File

@ -36,6 +36,7 @@ type AgentOption = {
const AGENT_OPTIONS: AgentOption[] = [
{ id: 'claude', label: 'Claude' },
{ id: 'codex', label: 'Codex' },
{ id: 'grok', label: 'Grok', faviconDomain: 'x.ai' },
{ id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' },
{ id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' },
{ id: 'pi', label: 'Pi' },
@ -67,6 +68,7 @@ const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL]
const AGENT_COMMANDS: Record<string, string> = {
claude: 'claude',
codex: 'codex',
grok: 'grok',
copilot: 'copilot',
opencode: 'opencode',
pi: 'pi',

View File

@ -11,6 +11,7 @@ import { CodexHookService } from '../codex/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { ClaudeHookService } from '../claude/hook-service'
import { GrokHookService } from '../grok/hook-service'
type FakeFs = {
files: Map<string, string>
@ -124,6 +125,10 @@ describe('remote hook service installers', () => {
{
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
},
{
path: '/home/dev/.orca/agent-hooks/grok-hook.sh',
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev')
}
]
@ -185,12 +190,14 @@ describe('remote hook service installers', () => {
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
})
it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => {
it('installs remote Gemini, Cursor, and Grok configs using their CLI-specific schemas', async () => {
const gemini = createFakeSftp()
const cursor = createFakeSftp()
const grok = createFakeSftp()
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
await new GrokHookService().installRemote(grok.sftp, '/home/dev')
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
@ -220,5 +227,25 @@ describe('remote hook service installers', () => {
expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
expect(definition?.hooks).toBeUndefined()
}
const grokConfig = JSON.parse(grok.fs.files.get('/home/dev/.grok/hooks/orca-status.json')!) as {
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
}
for (const eventName of [
'SessionStart',
'UserPromptSubmit',
'Stop',
'SessionEnd',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'Notification'
]) {
const definition = grokConfig.hooks[eventName]?.[0]
const command = definition?.hooks?.[0]?.command
expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh')
expect(command).toMatch(/^if \[ -x /)
}
expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*')
})
})

View File

@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('os', async () => {
const actual = (await vi.importActual('os')) as Record<string, unknown>
return {
...actual,
homedir: homedirMock
}
})
import { GrokHookService } from './hook-service'
describe('GrokHookService', () => {
let homeDir: string
beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'orca-grok-home-'))
homedirMock.mockReturnValue(homeDir)
})
afterEach(() => {
vi.clearAllMocks()
rmSync(homeDir, { recursive: true, force: true })
})
it('installs a dedicated global Grok hook config and managed script', () => {
const status = new GrokHookService().install()
expect(status.state).toBe('installed')
expect(status.configPath).toBe(join(homeDir, '.grok', 'hooks', 'orca-status.json'))
expect(status.managedHooksPresent).toBe(true)
const config = JSON.parse(
readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8')
) as {
hooks: Record<string, { matcher?: string; hooks: { command: string }[] }[]>
}
expect(Object.keys(config.hooks).sort()).toEqual(
[
'Notification',
'PostToolUse',
'PostToolUseFailure',
'PreToolUse',
'SessionEnd',
'SessionStart',
'Stop',
'UserPromptSubmit'
].sort()
)
expect(config.hooks.PreToolUse[0].matcher).toBe('*')
expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*')
expect(config.hooks.Notification[0].matcher).toBeUndefined()
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook')
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'grok-hook.sh'), 'utf8')
expect(script).toContain('/hook/grok')
expect(script).toContain('payload=$(cat)')
})
it('preserves user-authored hook entries in the Orca Grok config file', () => {
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
mkdirSync(dirname(configPath), { recursive: true })
writeFileSync(
configPath,
`${JSON.stringify(
{
hooks: {
Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }]
}
},
null,
2
)}\n`
)
new GrokHookService().install()
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
hooks: Record<string, { hooks: { command: string }[] }[]>
}
const commands = config.hooks.Notification.flatMap((definition) =>
definition.hooks.map((hook) => hook.command)
)
expect(commands).toContain('/usr/local/bin/user-hook')
expect(commands.some((command) => command.includes('grok-hook.sh'))).toBe(true)
})
})

View File

@ -0,0 +1,276 @@
import { homedir } from 'os'
import { join } from 'path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
getSharedManagedScriptPath,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
const GROK_EVENTS = [
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } },
{
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: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } }
] as const
function getConfigPath(): string {
// Why: Grok loads trusted global hook files from ~/.grok/hooks/*.json. Keep
// Orca's managed entries in a dedicated file so user-authored hook files stay
// untouched and project-level trust is not required for status reporting.
return join(homedir(), '.grok', 'hooks', 'orca-status.json')
}
function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
}
function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
function getManagedCommand(scriptPath: string): string {
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/grok') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
'exit /b 0',
''
].join('\r\n')
}
return [
'#!/bin/sh',
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
'payload=$(cat)',
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
}
function buildInstalledConfig(
config: NonNullable<ReturnType<typeof readHooksJson>>,
command: string,
scriptFileName: string
): void {
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
const managedEvents = new Set<string>(GROK_EVENTS.map((event) => event.eventName))
// Why: Orca owns only grok-hook.* entries. Sweep stale managed commands out
// of retired events while preserving any user-authored hooks in this file.
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
for (const event of GROK_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
}
export class GrokHookService {
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'grok',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Grok hook config'
}
}
const command = getManagedCommand(scriptPath)
const missing: string[] = []
let presentCount = 0
for (const event of GROK_EVENTS) {
const definitions = Array.isArray(config.hooks?.[event.eventName])
? config.hooks![event.eventName]!
: []
const hasCommand = definitions.some((definition) =>
(definition.hooks ?? []).some((hook) => hook.command === command)
)
if (hasCommand) {
presentCount += 1
} else {
missing.push(event.eventName)
}
}
const managedHooksPresent = presentCount > 0
let state: AgentHookInstallState
let detail: string | null
if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: 'grok', state, configPath, managedHooksPresent, detail }
}
install(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'grok',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Grok hook config'
}
}
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const home = remoteHome.replace(/\/$/, '')
const remoteConfigPath = `${home}/.grok/hooks/orca-status.json`
const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'grok',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Grok hook config'
}
}
buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'grok-hook.sh')
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
return {
agent: 'grok',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'grok',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'grok',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Grok hook config'
}
}
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
config.hooks = nextHooks
writeHooksJson(configPath, config)
return this.getStatus()
}
}
export const grokHookService = new GrokHookService()

View File

@ -55,6 +55,7 @@ import { codexHookService } from './codex/hook-service'
import { geminiHookService } from './gemini/hook-service'
import { cursorHookService } from './cursor/hook-service'
import { droidHookService } from './droid/hook-service'
import { grokHookService } from './grok/hook-service'
import {
getPtyIdForPaneKey,
registerPaneKeyTeardownListener,
@ -712,7 +713,8 @@ app.whenReady().then(async () => {
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()]
['droid', () => droidHookService.install()],
['grok', () => grokHookService.install()]
])
registerAppMenu({

View File

@ -53,6 +53,12 @@ vi.mock('../gemini/hook-service', () => ({
vi.mock('../cursor/hook-service', () => ({
cursorHookService: { getStatus: vi.fn(() => ({ agent: 'cursor', state: 'absent' })) }
}))
vi.mock('../droid/hook-service', () => ({
droidHookService: { getStatus: vi.fn(() => ({ agent: 'droid', state: 'absent' })) }
}))
vi.mock('../grok/hook-service', () => ({
grokHookService: { getStatus: vi.fn(() => ({ agent: 'grok', state: 'absent' })) }
}))
beforeEach(() => {
dropStatusEntry.mockReset()

View File

@ -14,6 +14,7 @@ import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { grokHookService } from '../grok/hook-service'
// Why: install/remove are intentionally not exposed to the renderer. Orca
// auto-installs managed hooks at app startup (see src/main/index.ts), so a
@ -31,6 +32,7 @@ export function registerAgentHookHandlers(): void {
ipcMain.removeHandler('agentHooks:geminiStatus')
ipcMain.removeHandler('agentHooks:cursorStatus')
ipcMain.removeHandler('agentHooks:droidStatus')
ipcMain.removeHandler('agentHooks:grokStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
@ -133,4 +135,17 @@ export function registerAgentHookHandlers(): void {
}
}
})
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)
}
}
})
}

View File

@ -1021,6 +1021,7 @@ export type PreloadApi = {
geminiStatus: () => Promise<AgentHookInstallStatus>
cursorStatus: () => Promise<AgentHookInstallStatus>
droidStatus: () => Promise<AgentHookInstallStatus>
grokStatus: () => Promise<AgentHookInstallStatus>
}
agentTrust: {
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise<void>

View File

@ -1092,7 +1092,9 @@ const api = {
ipcRenderer.invoke('agentHooks:geminiStatus'),
cursorStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:cursorStatus'),
droidStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:droidStatus')
droidStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:droidStatus'),
grokStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:grokStatus')
},
agentTrust: {

View File

@ -31,6 +31,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'hermes',
'openclaw',
'copilot',
'grok',
'github',
'github copilot',
'command',

View File

@ -93,7 +93,8 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'pi',
'gemini',
'aider',
'copilot'
'copilot',
'grok'
]
}
]

View File

@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
type AgentStatusEntry,
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/types'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import { buildWorktreeAgentRows } from './useWorktreeAgentRows'
import {
buildWorktreeAgentRows,
selectMigrationUnsupportedEntriesForWorktree
} from './useWorktreeAgentRows'
import { makePaneKey } from '../../../../shared/stable-pane-id'
const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111')
@ -108,3 +112,34 @@ describe('buildWorktreeAgentRows', () => {
expect(done?.state).toBe('done')
})
})
describe('selectMigrationUnsupportedEntriesForWorktree', () => {
it('returns raw migration records so shallow selectors can cache snapshots', () => {
const unsupported: MigrationUnsupportedPtyEntry = {
ptyId: 'pty-1',
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: '44444444-4444-4444-8444-444444444444',
paneKey: makePaneKey('tab-1', '44444444-4444-4444-8444-444444444444'),
reason: 'legacy-numeric-pane-key',
source: 'local',
updatedAt: 1000
}
const state = {
tabsByWorktree: { 'wt-1': [makeTab('tab-1')] },
agentStatusByPaneKey: {},
migrationUnsupportedByPtyId: { 'pty-1': unsupported },
retainedAgentsByPaneKey: {}
}
const first = selectMigrationUnsupportedEntriesForWorktree(state, 'wt-1')
const second = selectMigrationUnsupportedEntriesForWorktree(state, 'wt-1')
// Why: the Electron black-screen regression came from creating converted
// AgentStatusEntry objects inside the Zustand selector. Returning store
// records preserves element identity for useShallow.
expect(first).toEqual([unsupported])
expect(second).toEqual([unsupported])
expect(first[0]).toBe(second[0])
})
})

View File

@ -4,10 +4,12 @@ import { useAppStore } from '@/store'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import type { AppState } from '@/store/types'
import type { TerminalTab } from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
type AgentStatusEntry,
type MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
@ -18,8 +20,76 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor
// unnecessary re-renders — defeating the purpose of the narrow selector.
const EMPTY_TABS: TerminalTab[] = []
const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
type WorktreeAgentRowsState = Pick<
AppState,
| 'agentStatusByPaneKey'
| 'migrationUnsupportedByPtyId'
| 'retainedAgentsByPaneKey'
| 'tabsByWorktree'
>
export function selectLiveAgentStatusEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): AgentStatusEntry[] {
const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_LIVE_ENTRIES
}
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: AgentStatusEntry[] = []
for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
}
if (!tabIds.has(parsed.tabId)) {
continue
}
out.push(entry)
}
return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
}
export function selectMigrationUnsupportedEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): MigrationUnsupportedPtyEntry[] {
const wtTabs = state.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
}
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: MigrationUnsupportedPtyEntry[] = []
for (const unsupported of Object.values(state.migrationUnsupportedByPtyId)) {
if (!unsupported.paneKey) {
continue
}
const parsed = parsePaneKey(unsupported.paneKey)
if (!parsed || !tabIds.has(parsed.tabId)) {
continue
}
out.push(unsupported)
}
return out.length > 0 ? out : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES
}
export function selectRetainedAgentEntriesForWorktree(
state: WorktreeAgentRowsState,
worktreeId: string
): RetainedAgentEntry[] {
const out: RetainedAgentEntry[] = []
for (const ra of Object.values(state.retainedAgentsByPaneKey)) {
if (ra.worktreeId === worktreeId) {
out.push(ra)
}
}
return out.length > 0 ? out : EMPTY_RETAINED
}
export function buildWorktreeAgentRows(args: {
tabs: TerminalTab[]
entries: AgentStatusEntry[]
@ -97,48 +167,17 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
// every on-screen card re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card
// only re-renders when something relevant to THIS worktree changes.
const entries = useAppStore(
useShallow((s) => {
const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_LIVE_ENTRIES
}
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: AgentStatusEntry[] = []
for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
continue
}
if (!tabIds.has(parsed.tabId)) {
continue
}
out.push(entry)
}
for (const unsupported of Object.values(s.migrationUnsupportedByPtyId ?? {})) {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
if (!entry) {
continue
}
const parsed = parsePaneKey(entry.paneKey)
if (!parsed || !tabIds.has(parsed.tabId)) {
continue
}
out.push(entry)
}
return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
})
const liveEntries = useAppStore(
useShallow((s) => selectLiveAgentStatusEntriesForWorktree(s, worktreeId))
)
// Why: keep the store selector limited to stable raw records. Converting
// migration entries creates fresh objects with Date.now(), which breaks
// useSyncExternalStore's cached-snapshot contract and can blank Electron.
const migrationUnsupported = useAppStore(
useShallow((s) => selectMigrationUnsupportedEntriesForWorktree(s, worktreeId))
)
const retained = useAppStore(
useShallow((s) => {
const out: RetainedAgentEntry[] = []
for (const ra of Object.values(s.retainedAgentsByPaneKey)) {
if (ra.worktreeId === worktreeId) {
out.push(ra)
}
}
return out.length > 0 ? out : EMPTY_RETAINED
})
useShallow((s) => selectRetainedAgentEntriesForWorktree(s, worktreeId))
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries
@ -150,12 +189,23 @@ export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks — same pattern as
// useDashboardData.
const now = Date.now()
const entries =
migrationUnsupported.length > 0
? [
...liveEntries,
...migrationUnsupported.flatMap((unsupported) => {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
return entry ? [entry] : []
})
]
: liveEntries
return buildWorktreeAgentRows({
tabs: tabs ?? [],
entries,
retained,
now: Date.now()
now
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabs, entries, retained, agentStatusEpoch])
}, [tabs, liveEntries, migrationUnsupported, retained, agentStatusEpoch])
}

View File

@ -28,6 +28,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [
cmd: 'codex',
homepageUrl: 'https://github.com/openai/codex'
},
{
id: 'grok',
label: 'Grok',
cmd: 'grok',
faviconDomain: 'x.ai',
homepageUrl: 'https://x.ai/cli'
},
{
id: 'copilot',
label: 'GitHub Copilot',

View File

@ -377,6 +377,7 @@ describe('getAgentLabel', () => {
expect(getAgentLabel('✦ Gemini CLI')).toBe('Gemini CLI')
expect(getAgentLabel('⠂ Claude Code')).toBe('Claude Code')
expect(getAgentLabel('⠋ Codex is thinking')).toBe('Codex')
expect(getAgentLabel('Grok running')).toBe('Grok')
expect(getAgentLabel('⠋ Droid')).toBe('Droid')
expect(getAgentLabel('Droid ready')).toBe('Droid')
})

View File

@ -117,7 +117,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
cursor: 'Cursor',
aider: 'Aider',
pi: 'Pi',
droid: 'Droid'
droid: 'Droid',
grok: 'Grok'
}
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
@ -166,7 +167,8 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
rovo: true,
hermes: true,
openclaw: true,
copilot: true
copilot: true,
grok: true
}
export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent | null {

View File

@ -118,6 +118,22 @@ describe('buildAgentStartupPlan', () => {
})
})
it('launches Grok first and injects the prompt after startup', () => {
expect(
buildAgentStartupPlan({
agent: 'grok',
prompt: 'Trace the failing test',
cmdOverrides: {},
platform: 'linux'
})
).toEqual({
agent: 'grok',
launchCommand: 'grok',
expectedProcess: 'grok',
followupPrompt: 'Trace the failing test'
})
})
it('returns null when there is no prompt to inject', () => {
expect(
buildAgentStartupPlan({

View File

@ -791,7 +791,7 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
}
function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid') =>
const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok') =>
Promise.resolve({
agent,
state: 'not_installed',
@ -804,7 +804,8 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
codexStatus: () => status('codex'),
geminiStatus: () => status('gemini'),
cursorStatus: () => status('cursor'),
droidStatus: () => status('droid')
droidStatus: () => status('droid'),
grokStatus: () => status('grok')
}
}

View File

@ -29,7 +29,8 @@ export const AGENT_NAMES = [
'gemini',
'opencode',
'openclaw',
'aider'
'aider',
'grok'
]
// Why: `android` contains `droid`; unlike the legacy agent names above, Droid
@ -354,6 +355,9 @@ export function getAgentLabel(title: string): string | null {
if (lower.includes('copilot')) {
return 'GitHub Copilot'
}
if (lower.includes('grok')) {
return 'Grok'
}
if (lower.includes('opencode')) {
return 'OpenCode'
}

View File

@ -33,6 +33,7 @@ describe('shared agent-hook-listener', () => {
it('routes pathnames to a known source or null', () => {
expect(resolveHookSource('/hook/claude')).toBe('claude')
expect(resolveHookSource('/hook/cursor')).toBe('cursor')
expect(resolveHookSource('/hook/grok')).toBe('grok')
expect(resolveHookSource('/hook/unknown')).toBeNull()
expect(resolveHookSource('/')).toBeNull()
})
@ -123,6 +124,75 @@ describe('shared agent-hook-listener', () => {
expect(event!.payload.prompt).toBe('')
})
it('normalizes Grok hookEventName payloads and keeps prompt across tool events', () => {
const prompt = normalizeHookPayload(
state,
'grok',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
payload: { hookEventName: 'user_prompt_submit', prompt: 'run the check' }
},
'production'
)
expect(prompt).not.toBeNull()
expect(prompt!.payload).toMatchObject({
state: 'working',
prompt: 'run the check',
agentType: 'grok'
})
const tool = normalizeHookPayload(
state,
'grok',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
payload: {
hookEventName: 'pre_tool_use',
toolName: 'run_terminal_cmd',
toolInput: { command: 'pnpm test' }
}
},
'production'
)
expect(tool).not.toBeNull()
expect(tool!.payload).toMatchObject({
state: 'working',
prompt: 'run the check',
agentType: 'grok',
toolName: 'run_terminal_cmd',
toolInput: 'pnpm test'
})
})
it('maps Grok feedback notifications to waiting without overwriting the prompt', () => {
normalizeHookPayload(
state,
'grok',
{ paneKey: PANE_KEY, payload: { hookEventName: 'UserPromptSubmit', prompt: 'ship it' } },
'production'
)
const event = normalizeHookPayload(
state,
'grok',
{
paneKey: PANE_KEY,
payload: { hookEventName: 'Notification', message: 'Grok needs your feedback to proceed' }
},
'production'
)
expect(event).not.toBeNull()
expect(event!.payload).toMatchObject({
state: 'waiting',
prompt: 'ship it',
agentType: 'grok'
})
})
describe('writeEndpointFile', () => {
let dir: string
beforeEach(() => {

View File

@ -305,6 +305,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
google_web_search: ['query'],
exec_command: ['cmd', 'command'],
shell_command: ['cmd', 'command'],
run_terminal_cmd: ['command'],
apply_patch: ['path', 'file_path'],
view_image: ['path', 'file_path'],
bash: ['command'],
@ -741,8 +742,102 @@ function extractDroidToolFields(
return {}
}
function normalizeHookEventName(value: unknown): string {
if (typeof value !== 'string') {
return ''
}
return value
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[-\s]+/g, '_')
.toLowerCase()
}
function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean {
const normalized = normalizeHookEventName(eventName)
return expected.includes(normalized)
}
function extractGrokToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) {
const toolName =
readString(hookPayload, 'toolName') ??
readString(hookPayload, 'tool_name') ??
readString(hookPayload, 'name')
const toolInput =
deriveToolInputPreview(toolName, hookPayload.toolInput) ??
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
deriveToolInputPreview(toolName, hookPayload.input) ??
deriveToolInputPreview(toolName, hookPayload.arguments)
const update: ToolSnapshot = { toolName, toolInput }
if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) {
const responseText =
extractToolResponseText(hookPayload.toolResponse) ??
extractToolResponseText(hookPayload.tool_response) ??
extractToolResponseText(hookPayload.toolOutput) ??
extractToolResponseText(hookPayload.tool_output) ??
readString(hookPayload, 'error') ??
readString(hookPayload, 'message')
if (responseText) {
update.lastAssistantMessage = responseText
}
}
return update
}
if (isGrokEvent(eventName, 'stop', 'session_end')) {
const direct =
readString(hookPayload, 'lastAssistantMessage') ??
readString(hookPayload, 'last_assistant_message')
if (direct) {
return { lastAssistantMessage: direct }
}
const fromTranscript = readLastAssistantFromTranscript(
hookPayload.transcriptPath ?? hookPayload.transcript_path
)
if (fromTranscript) {
return { lastAssistantMessage: fromTranscript }
}
}
return {}
}
function isGrokPermissionNotification(message: string | undefined): boolean {
if (!message) {
return false
}
const lower = message.toLowerCase()
return (
lower.includes('permission') ||
lower.includes('approval') ||
lower.includes('approve') ||
lower.includes('allow') ||
lower.includes('confirm') ||
lower.includes('needs your') ||
lower.includes('requires your') ||
lower.includes('feedback') ||
lower.includes('clarify') ||
lower.includes('question')
)
}
function isGrokIdleNotification(message: string | undefined): boolean {
if (!message) {
return false
}
const lower = message.toLowerCase()
return (
lower.includes('type your message') ||
lower.includes('enter send') ||
lower.includes('shift-tab normal') ||
lower.includes('ask a side question')
)
}
function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
// Why: exhaustive switch so adding an 8th source to AgentHookSource fails
// Why: exhaustive switch so adding a source to AgentHookSource fails
// typecheck here instead of silently falling through to `false`.
switch (source) {
case 'claude':
@ -759,6 +854,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
return eventName === 'before_agent_start'
case 'droid':
return eventName === 'UserPromptSubmit'
case 'grok':
return isGrokEvent(eventName, 'user_prompt_submit')
default: {
const _exhaustive: never = source
void _exhaustive
@ -772,7 +869,7 @@ function extractToolFields(
eventName: unknown,
hookPayload: Record<string, unknown>
): ToolSnapshot {
// Why: exhaustive switch so adding an 8th source to AgentHookSource fails
// Why: exhaustive switch so adding a source to AgentHookSource fails
// typecheck here instead of silently routing through OpenCode's extractor.
switch (source) {
case 'claude':
@ -789,6 +886,8 @@ function extractToolFields(
return extractPiToolFields(eventName, hookPayload)
case 'droid':
return extractDroidToolFields(eventName, hookPayload)
case 'grok':
return extractGrokToolFields(eventName, hookPayload)
default: {
const _exhaustive: never = source
void _exhaustive
@ -1141,6 +1240,75 @@ function normalizeDroidEvent(
)
}
function normalizeGrokEvent(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
if (isGrokEvent(eventName, 'session_start')) {
// Why: Grok emits SessionStart when the TUI opens/resumes. It should reset
// stale per-turn details without creating a visible "working" row before a
// user prompt or tool event exists.
clearPaneTurnCacheState(state, paneKey)
return null
}
const notificationMessage = readString(hookPayload, 'message')
let stateName: 'working' | 'waiting' | 'done' | null = null
if (
isGrokEvent(
eventName,
'user_prompt_submit',
'pre_tool_use',
'post_tool_use',
'post_tool_use_failure'
)
) {
stateName = 'working'
} else if (isGrokEvent(eventName, 'stop', 'session_end')) {
stateName = 'done'
} else if (
isGrokEvent(eventName, 'notification') &&
isGrokPermissionNotification(notificationMessage)
) {
stateName = 'waiting'
} else if (
isGrokEvent(eventName, 'notification') &&
isGrokIdleNotification(notificationMessage)
) {
stateName = 'done'
}
if (!stateName) {
return null
}
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('grok', eventName, hookPayload),
{ resetOnNewTurn: isNewTurnEvent('grok', eventName) }
)
// Why: Grok Notification.message is status UI text, not necessarily the
// user's prompt. Preserve the cached UserPromptSubmit prompt for the row.
const effectivePrompt = isGrokEvent(eventName, 'notification') ? '' : promptText
return parseAgentStatusPayload(
JSON.stringify({
state: stateName,
prompt: resolvePrompt(state, paneKey, effectivePrompt, {
resetOnNewTurn: isNewTurnEvent('grok', eventName)
}),
agentType: 'grok',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
lastAssistantMessage: snapshot.lastAssistantMessage
})
)
}
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
if (typeof value !== 'string') {
@ -1196,10 +1364,10 @@ export function normalizeHookPayload(
}
const worktreeId = readStringField(record, 'worktreeId')
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
const hookPayloadRecord = hookPayload as Record<string, unknown>
// Why: exhaustive switch so adding an 8th source to AgentHookSource fails
const eventName = hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
// Why: exhaustive switch so adding a source to AgentHookSource fails
// typecheck here instead of silently routing through OpenCode's normalizer.
let payload: ParsedAgentStatusPayload | null
switch (source) {
@ -1224,6 +1392,9 @@ export function normalizeHookPayload(
case 'droid':
payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
case 'grok':
payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
default: {
const _exhaustive: never = source
void _exhaustive
@ -1247,7 +1418,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
'/hook/opencode': 'opencode',
'/hook/cursor': 'cursor',
'/hook/pi': 'pi',
'/hook/droid': 'droid'
'/hook/droid': 'droid',
'/hook/grok': 'grok'
})
export function resolveHookSource(pathname: string): AgentHookSource | null {

View File

@ -30,7 +30,15 @@ import type { ParsedAgentStatusPayload } from './agent-status-types'
// Promoted from `src/main/agent-hooks/server.ts` so the relay can import it
// without dragging Electron in (the shared listener module is the only place
// that consumes it from the relay side).
export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' | 'droid'
export type AgentHookSource =
| 'claude'
| 'codex'
| 'gemini'
| 'opencode'
| 'cursor'
| 'pi'
| 'droid'
| 'grok'
/** Env marker used by the remote relay. It is a transport/location marker, not
* a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */

View File

@ -4,7 +4,7 @@
// gemini/cursor/hook-service.ts). Lives in `shared/` to keep a single
// source of truth for the version string and status contract.
export const AGENT_HOOK_TARGETS = ['claude', 'codex', 'gemini', 'cursor', 'droid'] as const
export const AGENT_HOOK_TARGETS = ['claude', 'codex', 'gemini', 'cursor', 'droid', 'grok'] as const
export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number]
export type AgentHookInstallState = 'installed' | 'not_installed' | 'partial' | 'error'

View File

@ -38,7 +38,8 @@ const TUI_AGENT_KIND_BY_AGENT = {
rovo: 'rovo',
hermes: 'hermes',
openclaw: 'openclaw',
copilot: 'copilot'
copilot: 'copilot',
grok: 'grok'
} satisfies Record<TuiAgent, ConcreteAgentKind>
// Why: `satisfies Record<TuiAgent, …>` makes the lookup exhaustive at compile

View File

@ -18,6 +18,7 @@ export type WellKnownAgentType =
| 'aider'
| 'pi'
| 'droid'
| 'grok'
| 'unknown'
export type AgentType = WellKnownAgentType | (string & {})

View File

@ -60,6 +60,7 @@ export const AGENT_KIND_VALUES = [
'hermes',
'openclaw',
'copilot',
'grok',
'other'
] as const
export const agentKindSchema = z.enum(AGENT_KIND_VALUES)

View File

@ -232,6 +232,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
// `addTrustedFolder` writes after the user accepts) makes the menu skip
// entirely. See agent-trust-presets.ts for the file layout.
preflightTrust: 'copilot'
},
grok: {
detectCmd: 'grok',
launchCmd: 'grok',
expectedProcess: 'grok',
promptInjectionMode: 'stdin-after-start'
}
}

View File

@ -1145,6 +1145,7 @@ export type TuiAgent =
| 'hermes' // Hermes Agent
| 'openclaw' // OpenClaw
| 'copilot' // GitHub Copilot CLI
| 'grok' // xAI Grok CLI
export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'