Add Grok agent support
This commit is contained in:
parent
c90b9f72ef
commit
e7f3bb6034
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<p align="center">
|
||||
<strong>The AI Orchestrator for 100x builders.</strong><br/>
|
||||
Run Claude Code, Codex, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
|
||||
Run Claude Code, Codex, Grok, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
|
||||
Available for <strong>macOS, Windows, and Linux</strong>.
|
||||
</p>
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ Orca supports any CLI agent (*not just this list*).
|
|||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<p align="center">
|
||||
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
|
||||
Ejecuta Claude Code, Codex u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
|
||||
Ejecuta Claude Code, Codex, Grok u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
|
||||
Disponible para <strong>macOS, Windows y Linux</strong>.
|
||||
</p>
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
|
|||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ Orca は任意の CLI エージェントに対応しています(*このリス
|
|||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<p align="center">
|
||||
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
|
||||
Claude Code, Codex, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
|
||||
Claude Code, Codex, Grok, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
|
||||
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
|
||||
</p>
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(*아래 목록에만 한정
|
|||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
|
|||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" width="16" valign="middle" /> Hermes Agent</kbd></a>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type AgentOption = {
|
|||
const AGENT_OPTIONS: AgentOption[] = [
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'grok', label: 'Grok', faviconDomain: 'x.ai' },
|
||||
{ id: 'copilot', label: 'GitHub Copilot', faviconDomain: 'github.com' },
|
||||
{ id: 'opencode', label: 'OpenCode', faviconDomain: 'opencode.ai' },
|
||||
{ id: 'pi', label: 'Pi' },
|
||||
|
|
@ -67,6 +68,7 @@ const ALL_AGENTS = [...AGENT_OPTIONS, BLANK_TERMINAL]
|
|||
const AGENT_COMMANDS: Record<string, string> = {
|
||||
claude: 'claude',
|
||||
codex: 'codex',
|
||||
grok: 'grok',
|
||||
copilot: 'copilot',
|
||||
opencode: 'opencode',
|
||||
pi: 'pi',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { CodexHookService } from '../codex/hook-service'
|
|||
import { CursorHookService } from '../cursor/hook-service'
|
||||
import { GeminiHookService } from '../gemini/hook-service'
|
||||
import { ClaudeHookService } from '../claude/hook-service'
|
||||
import { GrokHookService } from '../grok/hook-service'
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
|
|
@ -124,6 +125,10 @@ describe('remote hook service installers', () => {
|
|||
{
|
||||
path: '/home/dev/.orca/agent-hooks/cursor-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new CursorHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/grok-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new GrokHookService().installRemote(sftp, '/home/dev')
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -185,12 +190,14 @@ describe('remote hook service installers', () => {
|
|||
expect(fs.files.get('/home/dev/.orca/agent-hooks/codex-hook.sh')).toContain('#!/bin/sh')
|
||||
})
|
||||
|
||||
it('installs remote Gemini and Cursor configs using their CLI-specific schemas', async () => {
|
||||
it('installs remote Gemini, Cursor, and Grok configs using their CLI-specific schemas', async () => {
|
||||
const gemini = createFakeSftp()
|
||||
const cursor = createFakeSftp()
|
||||
const grok = createFakeSftp()
|
||||
|
||||
await new GeminiHookService().installRemote(gemini.sftp, '/home/dev')
|
||||
await new CursorHookService().installRemote(cursor.sftp, '/home/dev')
|
||||
await new GrokHookService().installRemote(grok.sftp, '/home/dev')
|
||||
|
||||
const geminiConfig = JSON.parse(gemini.fs.files.get('/home/dev/.gemini/settings.json')!) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
|
|
@ -220,5 +227,25 @@ describe('remote hook service installers', () => {
|
|||
expect(definition?.command).toContain('/home/dev/.orca/agent-hooks/cursor-hook.sh')
|
||||
expect(definition?.hooks).toBeUndefined()
|
||||
}
|
||||
|
||||
const grokConfig = JSON.parse(grok.fs.files.get('/home/dev/.grok/hooks/orca-status.json')!) as {
|
||||
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
for (const eventName of [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'SessionEnd',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'Notification'
|
||||
]) {
|
||||
const definition = grokConfig.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = (await vi.importActual('os')) as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import { GrokHookService } from './hook-service'
|
||||
|
||||
describe('GrokHookService', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = mkdtempSync(join(tmpdir(), 'orca-grok-home-'))
|
||||
homedirMock.mockReturnValue(homeDir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('installs a dedicated global Grok hook config and managed script', () => {
|
||||
const status = new GrokHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe(join(homeDir, '.grok', 'hooks', 'orca-status.json'))
|
||||
expect(status.managedHooksPresent).toBe(true)
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8')
|
||||
) as {
|
||||
hooks: Record<string, { matcher?: string; hooks: { command: string }[] }[]>
|
||||
}
|
||||
expect(Object.keys(config.hooks).sort()).toEqual(
|
||||
[
|
||||
'Notification',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'PreToolUse',
|
||||
'SessionEnd',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'UserPromptSubmit'
|
||||
].sort()
|
||||
)
|
||||
expect(config.hooks.PreToolUse[0].matcher).toBe('*')
|
||||
expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*')
|
||||
expect(config.hooks.Notification[0].matcher).toBeUndefined()
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain('grok-hook')
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toContain(join(homeDir, '.orca'))
|
||||
|
||||
const script = readFileSync(join(homeDir, '.orca', 'agent-hooks', 'grok-hook.sh'), 'utf8')
|
||||
expect(script).toContain('/hook/grok')
|
||||
expect(script).toContain('payload=$(cat)')
|
||||
})
|
||||
|
||||
it('preserves user-authored hook entries in the Orca Grok config file', () => {
|
||||
const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json')
|
||||
mkdirSync(dirname(configPath), { recursive: true })
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Notification: [{ hooks: [{ type: 'command', command: '/usr/local/bin/user-hook' }] }]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
|
||||
new GrokHookService().install()
|
||||
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf8')) as {
|
||||
hooks: Record<string, { hooks: { command: string }[] }[]>
|
||||
}
|
||||
const commands = config.hooks.Notification.flatMap((definition) =>
|
||||
definition.hooks.map((hook) => hook.command)
|
||||
)
|
||||
expect(commands).toContain('/usr/local/bin/user-hook')
|
||||
expect(commands.some((command) => command.includes('grok-hook.sh'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
wrapPosixHookCommand,
|
||||
writeHooksJson,
|
||||
writeManagedScript,
|
||||
type HookDefinition
|
||||
} from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
readHooksJsonRemote,
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
|
||||
const GROK_EVENTS = [
|
||||
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{ eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
{
|
||||
eventName: 'PreToolUse',
|
||||
definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{
|
||||
eventName: 'PostToolUse',
|
||||
definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{
|
||||
eventName: 'PostToolUseFailure',
|
||||
definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] }
|
||||
},
|
||||
{ eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } }
|
||||
] as const
|
||||
|
||||
function getConfigPath(): string {
|
||||
// Why: Grok loads trusted global hook files from ~/.grok/hooks/*.json. Keep
|
||||
// Orca's managed entries in a dedicated file so user-authored hook files stay
|
||||
// untouched and project-level trust is not required for status reporting.
|
||||
return join(homedir(), '.grok', 'hooks', 'orca-status.json')
|
||||
}
|
||||
|
||||
function getManagedScriptFileName(): string {
|
||||
return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
|
||||
}
|
||||
|
||||
function getManagedScriptPath(): string {
|
||||
return getSharedManagedScriptPath(getManagedScriptFileName())
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string): string {
|
||||
return process.platform === 'win32' ? scriptPath : wrapPosixHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0',
|
||||
'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0',
|
||||
'if "%ORCA_PANE_KEY%"=="" exit /b 0',
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$inputData=[Console]::In.ReadToEnd(); if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }; try { $body=@{ paneKey=$env:ORCA_PANE_KEY; tabId=$env:ORCA_TAB_ID; worktreeId=$env:ORCA_WORKTREE_ID; env=$env:ORCA_AGENT_HOOK_ENV; version=$env:ORCA_AGENT_HOOK_VERSION; payload=($inputData | ConvertFrom-Json) } | ConvertTo-Json -Depth 100; Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/grok') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body | Out-Null } catch {}"`,
|
||||
'exit /b 0',
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
|
||||
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
|
||||
'fi',
|
||||
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'payload=$(cat)',
|
||||
'if [ -z "$payload" ]; then',
|
||||
' exit 0',
|
||||
'fi',
|
||||
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
|
||||
' -H "Content-Type: application/x-www-form-urlencoded" \\',
|
||||
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
|
||||
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
|
||||
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
|
||||
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
|
||||
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
||||
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
||||
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
|
||||
'exit 0',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildInstalledConfig(
|
||||
config: NonNullable<ReturnType<typeof readHooksJson>>,
|
||||
command: string,
|
||||
scriptFileName: string
|
||||
): void {
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
|
||||
const managedEvents = new Set<string>(GROK_EVENTS.map((event) => event.eventName))
|
||||
|
||||
// Why: Orca owns only grok-hook.* entries. Sweep stale managed commands out
|
||||
// of retired events while preserving any user-authored hooks in this file.
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of GROK_EVENTS) {
|
||||
const current = Array.isArray(nextHooks[event.eventName]) ? nextHooks[event.eventName] : []
|
||||
const cleaned = removeManagedCommands(current, isManagedCommand)
|
||||
const definition: HookDefinition = {
|
||||
...event.definition,
|
||||
hooks: [{ type: 'command', command }]
|
||||
}
|
||||
nextHooks[event.eventName] = [...cleaned, definition]
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
}
|
||||
|
||||
export class GrokHookService {
|
||||
getStatus(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
}
|
||||
|
||||
const command = getManagedCommand(scriptPath)
|
||||
const missing: string[] = []
|
||||
let presentCount = 0
|
||||
for (const event of GROK_EVENTS) {
|
||||
const definitions = Array.isArray(config.hooks?.[event.eventName])
|
||||
? config.hooks![event.eventName]!
|
||||
: []
|
||||
const hasCommand = definitions.some((definition) =>
|
||||
(definition.hooks ?? []).some((hook) => hook.command === command)
|
||||
)
|
||||
if (hasCommand) {
|
||||
presentCount += 1
|
||||
} else {
|
||||
missing.push(event.eventName)
|
||||
}
|
||||
}
|
||||
|
||||
const managedHooksPresent = presentCount > 0
|
||||
let state: AgentHookInstallState
|
||||
let detail: string | null
|
||||
if (missing.length === 0) {
|
||||
state = 'installed'
|
||||
detail = null
|
||||
} else if (presentCount === 0) {
|
||||
state = 'not_installed'
|
||||
detail = null
|
||||
} else {
|
||||
state = 'partial'
|
||||
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
||||
}
|
||||
return { agent: 'grok', state, configPath, managedHooksPresent, detail }
|
||||
}
|
||||
|
||||
install(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const scriptPath = getManagedScriptPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
}
|
||||
|
||||
buildInstalledConfig(config, getManagedCommand(scriptPath), getManagedScriptFileName())
|
||||
writeManagedScript(scriptPath, getManagedScript())
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
||||
const home = remoteHome.replace(/\/$/, '')
|
||||
const remoteConfigPath = `${home}/.grok/hooks/orca-status.json`
|
||||
const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh`
|
||||
try {
|
||||
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Grok hook config'
|
||||
}
|
||||
}
|
||||
|
||||
buildInstalledConfig(config, wrapPosixHookCommand(remoteScriptPath), 'grok-hook.sh')
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
|
||||
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'installed',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath: remoteConfigPath,
|
||||
managedHooksPresent: false,
|
||||
detail: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remove(): AgentHookInstallStatus {
|
||||
const configPath = getConfigPath()
|
||||
const config = readHooksJson(configPath)
|
||||
if (!config) {
|
||||
return {
|
||||
agent: 'grok',
|
||||
state: 'error',
|
||||
configPath,
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse Grok hook config'
|
||||
}
|
||||
}
|
||||
|
||||
const nextHooks = { ...config.hooks }
|
||||
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
|
||||
for (const [eventName, definitions] of Object.entries(nextHooks)) {
|
||||
if (!Array.isArray(definitions)) {
|
||||
continue
|
||||
}
|
||||
const cleaned = removeManagedCommands(definitions, isManagedCommand)
|
||||
if (cleaned.length === 0) {
|
||||
delete nextHooks[eventName]
|
||||
} else {
|
||||
nextHooks[eventName] = cleaned
|
||||
}
|
||||
}
|
||||
|
||||
config.hooks = nextHooks
|
||||
writeHooksJson(configPath, config)
|
||||
return this.getStatus()
|
||||
}
|
||||
}
|
||||
|
||||
export const grokHookService = new GrokHookService()
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1021,6 +1021,7 @@ export type PreloadApi = {
|
|||
geminiStatus: () => Promise<AgentHookInstallStatus>
|
||||
cursorStatus: () => Promise<AgentHookInstallStatus>
|
||||
droidStatus: () => Promise<AgentHookInstallStatus>
|
||||
grokStatus: () => Promise<AgentHookInstallStatus>
|
||||
}
|
||||
agentTrust: {
|
||||
markTrusted: (args: { preset: 'cursor' | 'copilot'; workspacePath: string }) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -1092,7 +1092,9 @@ const api = {
|
|||
ipcRenderer.invoke('agentHooks:geminiStatus'),
|
||||
cursorStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:cursorStatus'),
|
||||
droidStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:droidStatus')
|
||||
droidStatus: (): Promise<AgentHookInstallStatus> =>
|
||||
ipcRenderer.invoke('agentHooks:droidStatus'),
|
||||
grokStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:grokStatus')
|
||||
},
|
||||
|
||||
agentTrust: {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
'hermes',
|
||||
'openclaw',
|
||||
'copilot',
|
||||
'grok',
|
||||
'github',
|
||||
'github copilot',
|
||||
'command',
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
'pi',
|
||||
'gemini',
|
||||
'aider',
|
||||
'copilot'
|
||||
'copilot',
|
||||
'grok'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -117,7 +117,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
|
|||
cursor: 'Cursor',
|
||||
aider: 'Aider',
|
||||
pi: 'Pi',
|
||||
droid: 'Droid'
|
||||
droid: 'Droid',
|
||||
grok: 'Grok'
|
||||
}
|
||||
|
||||
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
|
||||
|
|
@ -166,7 +167,8 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
|
|||
rovo: true,
|
||||
hermes: true,
|
||||
openclaw: true,
|
||||
copilot: true
|
||||
copilot: true,
|
||||
grok: true
|
||||
}
|
||||
|
||||
export function agentTypeToIconAgent(agentType: AgentType | null | undefined): TuiAgent | null {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -791,7 +791,7 @@ function createCliApi(): NonNullable<Partial<PreloadApi>['cli']> {
|
|||
}
|
||||
|
||||
function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
||||
const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid') =>
|
||||
const status = (agent: 'claude' | 'codex' | 'gemini' | 'cursor' | 'droid' | 'grok') =>
|
||||
Promise.resolve({
|
||||
agent,
|
||||
state: 'not_installed',
|
||||
|
|
@ -804,7 +804,8 @@ function createAgentHooksApi(): NonNullable<Partial<PreloadApi>['agentHooks']> {
|
|||
codexStatus: () => status('codex'),
|
||||
geminiStatus: () => status('gemini'),
|
||||
cursorStatus: () => status('cursor'),
|
||||
droidStatus: () => status('droid')
|
||||
droidStatus: () => status('droid'),
|
||||
grokStatus: () => status('grok')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record<string, readonly string[]> = {
|
|||
google_web_search: ['query'],
|
||||
exec_command: ['cmd', 'command'],
|
||||
shell_command: ['cmd', 'command'],
|
||||
run_terminal_cmd: ['command'],
|
||||
apply_patch: ['path', 'file_path'],
|
||||
view_image: ['path', 'file_path'],
|
||||
bash: ['command'],
|
||||
|
|
@ -741,8 +742,102 @@ function extractDroidToolFields(
|
|||
return {}
|
||||
}
|
||||
|
||||
function normalizeHookEventName(value: unknown): string {
|
||||
if (typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
.trim()
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||
.replace(/[-\s]+/g, '_')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolean {
|
||||
const normalized = normalizeHookEventName(eventName)
|
||||
return expected.includes(normalized)
|
||||
}
|
||||
|
||||
function extractGrokToolFields(
|
||||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ToolSnapshot {
|
||||
if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) {
|
||||
const toolName =
|
||||
readString(hookPayload, 'toolName') ??
|
||||
readString(hookPayload, 'tool_name') ??
|
||||
readString(hookPayload, 'name')
|
||||
const toolInput =
|
||||
deriveToolInputPreview(toolName, hookPayload.toolInput) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.tool_input) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.input) ??
|
||||
deriveToolInputPreview(toolName, hookPayload.arguments)
|
||||
const update: ToolSnapshot = { toolName, toolInput }
|
||||
if (isGrokEvent(eventName, 'post_tool_use', 'post_tool_use_failure')) {
|
||||
const responseText =
|
||||
extractToolResponseText(hookPayload.toolResponse) ??
|
||||
extractToolResponseText(hookPayload.tool_response) ??
|
||||
extractToolResponseText(hookPayload.toolOutput) ??
|
||||
extractToolResponseText(hookPayload.tool_output) ??
|
||||
readString(hookPayload, 'error') ??
|
||||
readString(hookPayload, 'message')
|
||||
if (responseText) {
|
||||
update.lastAssistantMessage = responseText
|
||||
}
|
||||
}
|
||||
return update
|
||||
}
|
||||
if (isGrokEvent(eventName, 'stop', 'session_end')) {
|
||||
const direct =
|
||||
readString(hookPayload, 'lastAssistantMessage') ??
|
||||
readString(hookPayload, 'last_assistant_message')
|
||||
if (direct) {
|
||||
return { lastAssistantMessage: direct }
|
||||
}
|
||||
const fromTranscript = readLastAssistantFromTranscript(
|
||||
hookPayload.transcriptPath ?? hookPayload.transcript_path
|
||||
)
|
||||
if (fromTranscript) {
|
||||
return { lastAssistantMessage: fromTranscript }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function isGrokPermissionNotification(message: string | undefined): boolean {
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
return (
|
||||
lower.includes('permission') ||
|
||||
lower.includes('approval') ||
|
||||
lower.includes('approve') ||
|
||||
lower.includes('allow') ||
|
||||
lower.includes('confirm') ||
|
||||
lower.includes('needs your') ||
|
||||
lower.includes('requires your') ||
|
||||
lower.includes('feedback') ||
|
||||
lower.includes('clarify') ||
|
||||
lower.includes('question')
|
||||
)
|
||||
}
|
||||
|
||||
function isGrokIdleNotification(message: string | undefined): boolean {
|
||||
if (!message) {
|
||||
return false
|
||||
}
|
||||
const lower = message.toLowerCase()
|
||||
return (
|
||||
lower.includes('type your message') ||
|
||||
lower.includes('enter send') ||
|
||||
lower.includes('shift-tab normal') ||
|
||||
lower.includes('ask a side question')
|
||||
)
|
||||
}
|
||||
|
||||
function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
||||
// Why: exhaustive switch so adding an 8th source to AgentHookSource fails
|
||||
// Why: exhaustive switch so adding a source to AgentHookSource fails
|
||||
// typecheck here instead of silently falling through to `false`.
|
||||
switch (source) {
|
||||
case 'claude':
|
||||
|
|
@ -759,6 +854,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
|
|||
return eventName === 'before_agent_start'
|
||||
case 'droid':
|
||||
return eventName === 'UserPromptSubmit'
|
||||
case 'grok':
|
||||
return isGrokEvent(eventName, 'user_prompt_submit')
|
||||
default: {
|
||||
const _exhaustive: never = source
|
||||
void _exhaustive
|
||||
|
|
@ -772,7 +869,7 @@ function extractToolFields(
|
|||
eventName: unknown,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ToolSnapshot {
|
||||
// Why: exhaustive switch so adding an 8th source to AgentHookSource fails
|
||||
// Why: exhaustive switch so adding a source to AgentHookSource fails
|
||||
// typecheck here instead of silently routing through OpenCode's extractor.
|
||||
switch (source) {
|
||||
case 'claude':
|
||||
|
|
@ -789,6 +886,8 @@ function extractToolFields(
|
|||
return extractPiToolFields(eventName, hookPayload)
|
||||
case 'droid':
|
||||
return extractDroidToolFields(eventName, hookPayload)
|
||||
case 'grok':
|
||||
return extractGrokToolFields(eventName, hookPayload)
|
||||
default: {
|
||||
const _exhaustive: never = source
|
||||
void _exhaustive
|
||||
|
|
@ -1141,6 +1240,75 @@ function normalizeDroidEvent(
|
|||
)
|
||||
}
|
||||
|
||||
function normalizeGrokEvent(
|
||||
state: HookListenerState,
|
||||
eventName: unknown,
|
||||
promptText: string,
|
||||
paneKey: string,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ParsedAgentStatusPayload | null {
|
||||
if (isGrokEvent(eventName, 'session_start')) {
|
||||
// Why: Grok emits SessionStart when the TUI opens/resumes. It should reset
|
||||
// stale per-turn details without creating a visible "working" row before a
|
||||
// user prompt or tool event exists.
|
||||
clearPaneTurnCacheState(state, paneKey)
|
||||
return null
|
||||
}
|
||||
|
||||
const notificationMessage = readString(hookPayload, 'message')
|
||||
let stateName: 'working' | 'waiting' | 'done' | null = null
|
||||
if (
|
||||
isGrokEvent(
|
||||
eventName,
|
||||
'user_prompt_submit',
|
||||
'pre_tool_use',
|
||||
'post_tool_use',
|
||||
'post_tool_use_failure'
|
||||
)
|
||||
) {
|
||||
stateName = 'working'
|
||||
} else if (isGrokEvent(eventName, 'stop', 'session_end')) {
|
||||
stateName = 'done'
|
||||
} else if (
|
||||
isGrokEvent(eventName, 'notification') &&
|
||||
isGrokPermissionNotification(notificationMessage)
|
||||
) {
|
||||
stateName = 'waiting'
|
||||
} else if (
|
||||
isGrokEvent(eventName, 'notification') &&
|
||||
isGrokIdleNotification(notificationMessage)
|
||||
) {
|
||||
stateName = 'done'
|
||||
}
|
||||
if (!stateName) {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = resolveToolState(
|
||||
state,
|
||||
paneKey,
|
||||
extractToolFields('grok', eventName, hookPayload),
|
||||
{ resetOnNewTurn: isNewTurnEvent('grok', eventName) }
|
||||
)
|
||||
|
||||
// Why: Grok Notification.message is status UI text, not necessarily the
|
||||
// user's prompt. Preserve the cached UserPromptSubmit prompt for the row.
|
||||
const effectivePrompt = isGrokEvent(eventName, 'notification') ? '' : promptText
|
||||
|
||||
return parseAgentStatusPayload(
|
||||
JSON.stringify({
|
||||
state: stateName,
|
||||
prompt: resolvePrompt(state, paneKey, effectivePrompt, {
|
||||
resetOnNewTurn: isNewTurnEvent('grok', eventName)
|
||||
}),
|
||||
agentType: 'grok',
|
||||
toolName: snapshot.toolName,
|
||||
toolInput: snapshot.toolInput,
|
||||
lastAssistantMessage: snapshot.lastAssistantMessage
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key]
|
||||
if (typeof value !== 'string') {
|
||||
|
|
@ -1196,10 +1364,10 @@ export function normalizeHookPayload(
|
|||
}
|
||||
const worktreeId = readStringField(record, 'worktreeId')
|
||||
|
||||
const eventName = (hookPayload as Record<string, unknown>).hook_event_name
|
||||
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
|
||||
const hookPayloadRecord = hookPayload as Record<string, unknown>
|
||||
// Why: exhaustive switch so adding an 8th source to AgentHookSource fails
|
||||
const eventName = hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName
|
||||
const promptText = extractPromptText(hookPayload as Record<string, unknown>)
|
||||
// Why: exhaustive switch so adding a source to AgentHookSource fails
|
||||
// typecheck here instead of silently routing through OpenCode's normalizer.
|
||||
let payload: ParsedAgentStatusPayload | null
|
||||
switch (source) {
|
||||
|
|
@ -1224,6 +1392,9 @@ export function normalizeHookPayload(
|
|||
case 'droid':
|
||||
payload = normalizeDroidEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
case 'grok':
|
||||
payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
|
||||
break
|
||||
default: {
|
||||
const _exhaustive: never = source
|
||||
void _exhaustive
|
||||
|
|
@ -1247,7 +1418,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
|
|||
'/hook/opencode': 'opencode',
|
||||
'/hook/cursor': 'cursor',
|
||||
'/hook/pi': 'pi',
|
||||
'/hook/droid': 'droid'
|
||||
'/hook/droid': 'droid',
|
||||
'/hook/grok': 'grok'
|
||||
})
|
||||
|
||||
export function resolveHookSource(pathname: string): AgentHookSource | null {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ const TUI_AGENT_KIND_BY_AGENT = {
|
|||
rovo: 'rovo',
|
||||
hermes: 'hermes',
|
||||
openclaw: 'openclaw',
|
||||
copilot: 'copilot'
|
||||
copilot: 'copilot',
|
||||
grok: 'grok'
|
||||
} satisfies Record<TuiAgent, ConcreteAgentKind>
|
||||
|
||||
// Why: `satisfies Record<TuiAgent, …>` makes the lookup exhaustive at compile
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export type WellKnownAgentType =
|
|||
| 'aider'
|
||||
| 'pi'
|
||||
| 'droid'
|
||||
| 'grok'
|
||||
| 'unknown'
|
||||
export type AgentType = WellKnownAgentType | (string & {})
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export const AGENT_KIND_VALUES = [
|
|||
'hermes',
|
||||
'openclaw',
|
||||
'copilot',
|
||||
'grok',
|
||||
'other'
|
||||
] as const
|
||||
export const agentKindSchema = z.enum(AGENT_KIND_VALUES)
|
||||
|
|
|
|||
|
|
@ -232,6 +232,12 @@ export const TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {
|
|||
// `addTrustedFolder` writes after the user accepts) makes the menu skip
|
||||
// entirely. See agent-trust-presets.ts for the file layout.
|
||||
preflightTrust: 'copilot'
|
||||
},
|
||||
grok: {
|
||||
detectCmd: 'grok',
|
||||
launchCmd: 'grok',
|
||||
expectedProcess: 'grok',
|
||||
promptInjectionMode: 'stdin-after-start'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue