diff --git a/README.md b/README.md
index 5dfd755e0..ccd76d71f 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
The AI Orchestrator for 100x builders.
- Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
+ Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.
Available for macOS, Windows, and Linux.
@@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
Claude Code
Codex
+
Grok
Gemini
Pi
Hermes Agent
@@ -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.
diff --git a/docs/README.es.md b/docs/README.es.md
index 1147e3b7b..4701a21fb 100644
--- a/docs/README.es.md
+++ b/docs/README.es.md
@@ -14,7 +14,7 @@
El orquestador de IA para desarrolladores 100x.
- Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
+ Ejecuta Claude Code, Codex, Grok u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.
Disponible para macOS, Windows y Linux.
@@ -33,6 +33,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
Claude Code
Codex
+
Grok
Gemini
Pi
Hermes Agent
diff --git a/docs/README.ja.md b/docs/README.ja.md
index 35f688e94..bd7dcd3dc 100644
--- a/docs/README.ja.md
+++ b/docs/README.ja.md
@@ -33,6 +33,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス
Claude Code
Codex
+
Grok
Gemini
Pi
Hermes Agent
diff --git a/docs/README.ko.md b/docs/README.ko.md
index 42fabf357..070f16665 100644
--- a/docs/README.ko.md
+++ b/docs/README.ko.md
@@ -14,7 +14,7 @@
100x 빌더를 위한 AI 오케스트레이터.
- Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
+ Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
macOS, Windows, Linux에서 사용할 수 있습니다.
@@ -33,6 +33,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정
Claude Code
Codex
+
Grok
Gemini
Pi
Hermes Agent
diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md
index 855471739..aaa651d79 100644
--- a/docs/README.zh-CN.md
+++ b/docs/README.zh-CN.md
@@ -33,6 +33,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
Claude Code
Codex
+
Grok
Gemini
Pi
Hermes Agent
diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx
index a7e7d2579..20f74e7d6 100644
--- a/mobile/src/components/NewWorktreeModal.tsx
+++ b/mobile/src/components/NewWorktreeModal.tsx
@@ -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 = {
claude: 'claude',
codex: 'codex',
+ grok: 'grok',
copilot: 'copilot',
opencode: 'opencode',
pi: 'pi',
diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts
index 95db6b6ba..f273a47c8 100644
--- a/src/main/agent-hooks/remote-hook-service-installers.test.ts
+++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts
@@ -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
@@ -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
@@ -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
+ }
+ 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('*')
})
})
diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts
new file mode 100644
index 000000000..b64c70d7b
--- /dev/null
+++ b/src/main/grok/hook-service.test.ts
@@ -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
+ 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
+ }
+ 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
+ }
+ 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)
+ })
+})
diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts
new file mode 100644
index 000000000..0ffc12abb
--- /dev/null
+++ b/src/main/grok/hook-service.ts
@@ -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>,
+ command: string,
+ scriptFileName: string
+): void {
+ const nextHooks = { ...config.hooks }
+ const isManagedCommand = createManagedCommandMatcher(scriptFileName)
+ const managedEvents = new Set(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 {
+ 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()
diff --git a/src/main/index.ts b/src/main/index.ts
index e87525574..af7af86db 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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({
diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts
index f82ade795..75e76a74f 100644
--- a/src/main/ipc/agent-hooks.test.ts
+++ b/src/main/ipc/agent-hooks.test.ts
@@ -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()
diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts
index 350c1c920..2ecb426fe 100644
--- a/src/main/ipc/agent-hooks.ts
+++ b/src/main/ipc/agent-hooks.ts
@@ -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)
+ }
+ }
+ })
}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index ab03644b4..828d0be1c 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1021,6 +1021,7 @@ export type PreloadApi = {
geminiStatus: () => Promise
cursorStatus: () => Promise
droidStatus: () => Promise
+ grokStatus: () => Promise
}
agentTrust: {
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 314d84a13..a45f433e6 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1092,7 +1092,9 @@ const api = {
ipcRenderer.invoke('agentHooks:geminiStatus'),
cursorStatus: (): Promise =>
ipcRenderer.invoke('agentHooks:cursorStatus'),
- droidStatus: (): Promise => ipcRenderer.invoke('agentHooks:droidStatus')
+ droidStatus: (): Promise =>
+ ipcRenderer.invoke('agentHooks:droidStatus'),
+ grokStatus: (): Promise => ipcRenderer.invoke('agentHooks:grokStatus')
},
agentTrust: {
diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts
index 3d384e529..319f780f8 100644
--- a/src/renderer/src/components/settings/agents-search.ts
+++ b/src/renderer/src/components/settings/agents-search.ts
@@ -31,6 +31,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'hermes',
'openclaw',
'copilot',
+ 'grok',
'github',
'github copilot',
'command',
diff --git a/src/renderer/src/components/settings/general-search.ts b/src/renderer/src/components/settings/general-search.ts
index 26417f4d7..254b63336 100644
--- a/src/renderer/src/components/settings/general-search.ts
+++ b/src/renderer/src/components/settings/general-search.ts
@@ -93,7 +93,8 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'pi',
'gemini',
'aider',
- 'copilot'
+ 'copilot',
+ 'grok'
]
}
]
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
index 986e96d1f..a9827e3d2 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts
@@ -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])
+ })
+})
diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
index d0286bbeb..196e6a116 100644
--- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
+++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts
@@ -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])
}
diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx
index 30cac2a68..1d77e38ae 100644
--- a/src/renderer/src/lib/agent-catalog.tsx
+++ b/src/renderer/src/lib/agent-catalog.tsx
@@ -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',
diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts
index 770a6302c..5aaff7a92 100644
--- a/src/renderer/src/lib/agent-status.test.ts
+++ b/src/renderer/src/lib/agent-status.test.ts
@@ -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')
})
diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts
index 7f38952f2..f1a3a0de9 100644
--- a/src/renderer/src/lib/agent-status.ts
+++ b/src/renderer/src/lib/agent-status.ts
@@ -117,7 +117,8 @@ const WELL_KNOWN_LABELS: Record = {
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 = {
rovo: true,
hermes: true,
openclaw: true,
- copilot: true
+ copilot: true,
+ grok: true
}
export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent | null {
diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts
index 1adc0dd4a..aa634c5bf 100644
--- a/src/renderer/src/lib/tui-agent-startup.test.ts
+++ b/src/renderer/src/lib/tui-agent-startup.test.ts
@@ -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({
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 6d990c005..172742c0b 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -791,7 +791,7 @@ function createCliApi(): NonNullable['cli']> {
}
function createAgentHooksApi(): NonNullable['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['agentHooks']> {
codexStatus: () => status('codex'),
geminiStatus: () => status('gemini'),
cursorStatus: () => status('cursor'),
- droidStatus: () => status('droid')
+ droidStatus: () => status('droid'),
+ grokStatus: () => status('grok')
}
}
diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts
index c2ea5e881..fe824797c 100644
--- a/src/shared/agent-detection.ts
+++ b/src/shared/agent-detection.ts
@@ -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'
}
diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts
index fa3c0e8ac..543297555 100644
--- a/src/shared/agent-hook-listener.test.ts
+++ b/src/shared/agent-hook-listener.test.ts
@@ -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(() => {
diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts
index 3b5e2c36b..e3e93abc7 100644
--- a/src/shared/agent-hook-listener.ts
+++ b/src/shared/agent-hook-listener.ts
@@ -305,6 +305,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = {
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
+): 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
): 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
+): 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, 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).hook_event_name
- const promptText = extractPromptText(hookPayload as Record)
const hookPayloadRecord = hookPayload as Record
- // 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)
+ // 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>
'/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 {
diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts
index a0d78bdcc..d855f67d5 100644
--- a/src/shared/agent-hook-relay.ts
+++ b/src/shared/agent-hook-relay.ts
@@ -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. */
diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts
index 68cbf758b..1013dc841 100644
--- a/src/shared/agent-hook-types.ts
+++ b/src/shared/agent-hook-types.ts
@@ -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'
diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts
index ef26793cb..33585d5a8 100644
--- a/src/shared/agent-kind.ts
+++ b/src/shared/agent-kind.ts
@@ -38,7 +38,8 @@ const TUI_AGENT_KIND_BY_AGENT = {
rovo: 'rovo',
hermes: 'hermes',
openclaw: 'openclaw',
- copilot: 'copilot'
+ copilot: 'copilot',
+ grok: 'grok'
} satisfies Record
// Why: `satisfies Record` makes the lookup exhaustive at compile
diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts
index 402907d33..9bf00dc6e 100644
--- a/src/shared/agent-status-types.ts
+++ b/src/shared/agent-status-types.ts
@@ -18,6 +18,7 @@ export type WellKnownAgentType =
| 'aider'
| 'pi'
| 'droid'
+ | 'grok'
| 'unknown'
export type AgentType = WellKnownAgentType | (string & {})
diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts
index d5694770e..1878c7e0b 100644
--- a/src/shared/telemetry-events.ts
+++ b/src/shared/telemetry-events.ts
@@ -60,6 +60,7 @@ export const AGENT_KIND_VALUES = [
'hermes',
'openclaw',
'copilot',
+ 'grok',
'other'
] as const
export const agentKindSchema = z.enum(AGENT_KIND_VALUES)
diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts
index ad868812d..81b7dd699 100644
--- a/src/shared/tui-agent-config.ts
+++ b/src/shared/tui-agent-config.ts
@@ -232,6 +232,12 @@ export const TUI_AGENT_CONFIG: Record = {
// `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'
}
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index c6364d024..5f4fa70fb 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -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'