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'