diff --git a/README.md b/README.md index 450bb172..1dc8d858 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HAPI -Run official Claude Code / Codex / Gemini / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App. +Run official Claude Code / Codex / Cursor Agent / Grok Build / OpenCode sessions locally and control them remotely through a Web / PWA / Telegram Mini App. > **Why HAPI?** HAPI is a local-first alternative to Happy. See [Why Not Happy?](docs/guide/why-hapi.md) for the key differences. @@ -9,7 +9,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. -- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. +- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Grok Build, OpenCode—different agents, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. - **Workspace Browser** - Opt-in via one or more `hapi runner start --workspace-root ` flags: browse scoped file trees from the web and start sessions in allowed subdirectories. @@ -38,6 +38,7 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/ - [App](docs/guide/pwa.md) - [How it Works](docs/guide/how-it-works.md) - [Cursor Agent](docs/guide/cursor.md) +- [Grok Build](docs/guide/grok.md) - [Voice Assistant](docs/guide/voice-assistant.md) - [Why HAPI](docs/guide/why-hapi.md) - [FAQ](docs/guide/faq.md) diff --git a/cli/README.md b/cli/README.md index 7b707dce..0f67be46 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,12 +1,13 @@ # hapi CLI -Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub. +Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your terminal and control them remotely through the hapi hub. ## What it does - Starts Claude Code sessions and registers them with hapi-hub. - Starts Codex mode for OpenAI-based sessions. - Starts Cursor Agent mode for Cursor CLI sessions. +- Starts Grok Build locally or via ACP for remote sessions. - Starts Gemini mode via ACP (Anthropic Code Plugins). - Starts OpenCode mode via ACP and its plugin hook system. - Provides an MCP stdio bridge for external tools. @@ -30,6 +31,7 @@ Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your ter - `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. Local and remote modes supported; remote uses `agent -p` with stream-json. +- `hapi grok` - Start Grok Build mode. See `src/grok/runGrok.ts`. - `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`. Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram. - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. @@ -127,6 +129,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): - Claude CLI installed and logged in (`claude` on PATH). - Cursor Agent CLI installed (`agent` on PATH) for `hapi cursor`. Install: `curl https://cursor.com/install -fsS | bash` (macOS/Linux), `irm 'https://cursor.com/install?win32=true' | iex` (Windows). +- Grok Build CLI installed (`grok` on PATH) for `hapi grok`. Authenticate with `grok login --device-auth` on headless runner machines, or set `XAI_API_KEY`. - OpenCode CLI installed (`opencode` on PATH). - Bun for building from source. @@ -152,6 +155,7 @@ bun run build:single-exe - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. - `src/cursor/` - Cursor Agent integration. +- `src/grok/` - Grok Build native TUI + ACP integration. - `src/agent/` - Multi-agent support (Gemini via ACP). - `src/opencode/` - OpenCode ACP + hook integration. - `src/runner/` - Background service. diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index d0c32f56..cccbeb70 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -187,6 +187,79 @@ describe('AcpSdkBackend', () => { }); }); + it('captures Grok reasoning efforts from x.ai session metadata and switches with set_mode', async () => { + const backend = new AcpSdkBackend({ command: 'grok' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + transport: { sendRequest: (method: string, params: unknown) => Promise; close: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async (method, params) => { + calls.push({ method, params }); + if (method === 'session/new') { + return { + sessionId: 'grok-session-1', + models: { + currentModelId: 'grok-4.5', + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + _meta: { + reasoningEfforts: [ + { value: 'high', label: 'High Effort', default: true }, + { value: 'low', label: 'Low Effort', default: false } + ] + } + }] + }, + _meta: { + availableCommands: [{ name: 'auto' }], + 'x.ai/sessionConfig': { + options: [ + { id: 'high', category: 'mode', label: 'High Effort', selected: false }, + { id: 'low', category: 'mode', label: 'Low Effort', selected: true } + ] + } + } + }; + } + if (method === 'session/set_mode') return { meta: null }; + return null; + }, + close: async () => {} + }; + + const sessionId = await backend.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + expect(backend.getSessionModelsMetadata(sessionId)).toEqual({ + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + reasoningEfforts: [ + { value: 'high', name: 'High Effort', isDefault: true }, + { value: 'low', name: 'Low Effort', isDefault: false } + ] + }], + currentModelId: 'grok-4.5' + }); + expect(backend.getThoughtLevelConfigOption(sessionId)).toMatchObject({ + currentValue: 'low', + options: [ + { value: 'high', name: 'High Effort' }, + { value: 'low', name: 'Low Effort' } + ] + }); + expect(backend.hasAvailableCommand(sessionId, 'auto')).toBe(true); + + await backend.setMode(sessionId, 'high'); + + expect(calls).toContainEqual({ + method: 'session/set_mode', + params: { sessionId, modeId: 'high' } + }); + expect(backend.getThoughtLevelConfigOption(sessionId)?.currentValue).toBe('high'); + }); + it('merges configOptions model variants into availableModels when both are present', async () => { const backend = new AcpSdkBackend({ command: 'agent' }); const backendInternal = backend as unknown as { @@ -755,6 +828,52 @@ describe('AcpSdkBackend', () => { expect(realtimeUsage.map((m) => m.contextTokens)).toEqual([1_000, 2_500]); }); + it('forwards title changes from session_info_update', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const updates: Array<{ title?: string | null }> = []; + backend.setSessionInfoUpdateListener((update) => updates.push(update)); + + const backendInternal = backend as unknown as { + activeSessionId: string | null; + handleSessionUpdate: (params: unknown) => void; + }; + backendInternal.activeSessionId = 'session-1'; + + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 'Native ACP title' + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: null + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 123 + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'other-session', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 'Wrong session' + } + }); + + expect(updates).toEqual([ + { title: 'Native ACP title' }, + { title: null } + ]); + }); + it('emits a context-only usage on finalize when the prompt response carries no usage', async () => { backendStatics.UPDATE_QUIET_PERIOD_MS = 25; backendStatics.UPDATE_DRAIN_TIMEOUT_MS = 200; diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index ddbc474d..7e22bdcd 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -25,9 +25,14 @@ type AcpUsageUpdate = { contextWindow: number | undefined; }; +export type AcpSessionInfoUpdate = { + title?: string | null; +}; + export type AcpModelDescriptor = { modelId: string; name?: string; + reasoningEfforts?: Array<{ value: string; name?: string; isDefault?: boolean }>; }; export type AcpSessionModelsMetadata = { @@ -59,6 +64,9 @@ export class AcpSdkBackend implements AgentBackend { private readonly pendingPermissions = new Map(); private readonly sessionModelsMetadata = new Map(); private readonly sessionConfigOptions = new Map(); + private readonly initialAvailableCommands = new Set(); + private readonly sessionAvailableCommands = new Map>(); + private autoPermissionModeEnabled: boolean | null = null; private messageHandler: AcpMessageHandler | null = null; private activeSessionId: string | null = null; private initializeResult: AcpInitializeResult | null = null; @@ -69,6 +77,7 @@ export class AcpSdkBackend implements AgentBackend { private latestUsageUpdate: AcpUsageUpdate | null = null; private promptUsageCallback: ((msg: AgentMessage) => void) | null = null; private usageUpdateListener: ((msg: AgentMessage) => void) | null = null; + private sessionInfoUpdateListener: ((update: AcpSessionInfoUpdate) => void) | null = null; private lastForwardedUsageUpdate: AcpUsageUpdate | null = null; /** Retry configuration for ACP initialization */ @@ -120,6 +129,12 @@ export class AcpSdkBackend implements AgentBackend { this.transport.onNotification((method, params) => { if (method === 'session/update') { this.handleSessionUpdate(params); + } else if ( + method === '_x.ai/settings/update' + && isObject(params) + && 'auto_permission_mode_enabled' in params + ) { + this.autoPermissionModeEnabled = params.auto_permission_mode_enabled === true; } }); @@ -161,6 +176,8 @@ export class AcpSdkBackend implements AgentBackend { throw new Error('Invalid initialize response from ACP agent'); } + this.captureAvailableCommands(null, response); + this.initializeResult = { protocolVersion: response.protocolVersion, authMethods: Array.isArray(response.authMethods) @@ -238,6 +255,7 @@ export class AcpSdkBackend implements AgentBackend { try { await this.transport.sendRequest('session/set_mode', { sessionId, modeId }); this.setModeSupported = true; + this.updateThoughtLevelCurrentValue(sessionId, modeId); return; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -335,7 +353,7 @@ export class AcpSdkBackend implements AgentBackend { modelId }); - if (opts?.flavor === 'opencode') { + if (opts?.flavor === 'opencode' || opts?.flavor === 'grok') { // OpenCode's set_model response only carries an opaque `_meta` block, // not `availableModels`/`currentModelId`. Optimistically update the // cached currentModelId (the call succeeded, so the agent has switched) @@ -380,11 +398,24 @@ export class AcpSdkBackend implements AgentBackend { return this.sessionConfigOptions.get(sessionId)?.find((option) => option.category === 'thought_level'); } + hasAvailableCommand(sessionId: string, command: string): boolean { + if (command === 'auto' && this.autoPermissionModeEnabled === true) { + return true; + } + return this.sessionAvailableCommands.get(sessionId)?.has(command) + ?? this.initialAvailableCommands.has(command); + } + /** Forwards ACP `usage_update` to the web status bar when no prompt is active (e.g. session resume). */ setUsageUpdateListener(listener: ((msg: AgentMessage) => void) | null): void { this.usageUpdateListener = listener; } + /** Forwards ACP `session_info_update` metadata independently of prompt turns. */ + setSessionInfoUpdateListener(listener: ((update: AcpSessionInfoUpdate) => void) | null): void { + this.sessionInfoUpdateListener = listener; + } + async prompt( sessionId: string, content: PromptContent[], @@ -553,6 +584,9 @@ export class AcpSdkBackend implements AgentBackend { this.activeSessionId = null; this.isProcessingMessage = false; this.sessionModelsMetadata.clear(); + this.initialAvailableCommands.clear(); + this.sessionAvailableCommands.clear(); + this.autoPermissionModeEnabled = null; this.notifyResponseComplete(); await this.transport.close(); this.transport = null; @@ -566,10 +600,24 @@ export class AcpSdkBackend implements AgentBackend { } this.lastSessionUpdateAt = Date.now(); const update = params.update; + if (sessionId) { + this.captureAvailableCommands(sessionId, update); + } + this.captureSessionInfoUpdate(update); this.captureUsageUpdate(update); this.messageHandler?.handleUpdate(update); } + private captureSessionInfoUpdate(update: unknown): void { + if (!isObject(update)) return; + if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) return; + if (!Object.prototype.hasOwnProperty.call(update, 'title')) return; + + const title = update.title; + if (typeof title !== 'string' && title !== null) return; + this.sessionInfoUpdateListener?.({ title }); + } + private captureUsageUpdate(update: unknown): void { if (!isObject(update)) return; @@ -771,6 +819,16 @@ export class AcpSdkBackend implements AgentBackend { }); } + private updateThoughtLevelCurrentValue(sessionId: string, value: string): void { + const options = this.sessionConfigOptions.get(sessionId); + if (!options) return; + this.sessionConfigOptions.set(sessionId, options.map((option) => ( + option.category === 'thought_level' + ? { ...option, currentValue: value } + : option + ))); + } + /** After a successful model config apply, avoid stale base-only ACP currentValue overwriting cache. */ pinSessionModelWireId(sessionId: string, modelId: string): void { this.updateCurrentModelOptimistic(sessionId, modelId); @@ -805,12 +863,41 @@ export class AcpSdkBackend implements AgentBackend { private captureSessionMetadata(sessionId: string, response: unknown): void { this.captureSessionModelsMetadata(sessionId, response); this.captureSessionConfigOptions(sessionId, response); + this.captureAvailableCommands(sessionId, response); + } + + private captureAvailableCommands(sessionId: string | null, source: unknown): void { + if (!isObject(source)) return; + + const meta = isObject(source._meta) ? source._meta : null; + const rawCommands = Array.isArray(source.availableCommands) + ? source.availableCommands + : meta && Array.isArray(meta.availableCommands) + ? meta.availableCommands + : null; + if (!rawCommands) return; + + const commands = new Set( + rawCommands + .filter((entry): entry is Record => isObject(entry)) + .map((entry) => asString(entry.name) ?? '') + .filter((name) => name.length > 0) + ); + if (sessionId) { + this.sessionAvailableCommands.set(sessionId, commands); + return; + } + + this.initialAvailableCommands.clear(); + for (const command of commands) { + this.initialAvailableCommands.add(command); + } } private captureSessionConfigOptions(sessionId: string, response: unknown): void { - if (!isObject(response) || !Array.isArray(response.configOptions)) return; + if (!isObject(response)) return; - const options = response.configOptions + const options = (Array.isArray(response.configOptions) ? response.configOptions : []) .filter((entry): entry is Record => isObject(entry)) .map((entry): AcpConfigOptionDescriptor | null => { const id = asString(entry.id); @@ -831,7 +918,33 @@ export class AcpSdkBackend implements AgentBackend { }) .filter((entry): entry is AcpConfigOptionDescriptor => entry !== null); - this.sessionConfigOptions.set(sessionId, options); + const meta = isObject(response._meta) ? response._meta : null; + const xaiConfig = meta && isObject(meta['x.ai/sessionConfig']) + ? meta['x.ai/sessionConfig'] + : null; + const xaiOptions = xaiConfig && Array.isArray(xaiConfig.options) + ? xaiConfig.options.filter((entry): entry is Record => isObject(entry)) + : []; + const effortOptions = xaiOptions + .filter((entry) => asString(entry.category) === 'mode') + .map((entry) => ({ + value: asString(entry.id) ?? '', + name: asString(entry.label) ?? undefined, + selected: entry.selected === true + })) + .filter((entry) => entry.value.length > 0); + if (effortOptions.length > 0) { + options.push({ + id: 'x.ai/reasoning-effort', + category: 'thought_level', + currentValue: effortOptions.find((entry) => entry.selected)?.value, + options: effortOptions.map(({ value, name }) => ({ value, name })) + }); + } + + if (options.length > 0) { + this.sessionConfigOptions.set(sessionId, options); + } } /** @@ -886,7 +999,11 @@ export class AcpSdkBackend implements AgentBackend { } const byModelId = new Map(); - const addModel = (modelId: string, name?: string) => { + const addModel = ( + modelId: string, + name?: string, + reasoningEfforts?: AcpModelDescriptor['reasoningEfforts'] + ) => { const trimmedId = modelId.trim(); if (!trimmedId) return; const trimmedName = name?.trim(); @@ -895,13 +1012,13 @@ export class AcpSdkBackend implements AgentBackend { byModelId.set( trimmedId, trimmedName && trimmedName !== trimmedId - ? { modelId: trimmedId, name: trimmedName } - : { modelId: trimmedId } + ? { modelId: trimmedId, name: trimmedName, ...(reasoningEfforts ? { reasoningEfforts } : {}) } + : { modelId: trimmedId, ...(reasoningEfforts ? { reasoningEfforts } : {}) } ); return; } if (!existing.name && trimmedName && trimmedName !== trimmedId) { - byModelId.set(trimmedId, { modelId: trimmedId, name: trimmedName }); + byModelId.set(trimmedId, { ...existing, name: trimmedName }); } }; @@ -910,14 +1027,25 @@ export class AcpSdkBackend implements AgentBackend { if (!isObject(entry)) continue; const modelId = asString(entry.modelId) ?? asString(entry.value); if (!modelId) continue; - addModel(modelId, asString(entry.name) ?? undefined); + const meta = isObject(entry._meta) ? entry._meta : null; + const reasoningEfforts = meta && Array.isArray(meta.reasoningEfforts) + ? meta.reasoningEfforts + .filter((effort): effort is Record => isObject(effort)) + .map((effort) => ({ + value: asString(effort.value) ?? asString(effort.id) ?? '', + name: asString(effort.label) ?? undefined, + isDefault: effort.default === true + })) + .filter((effort) => effort.value.length > 0) + : undefined; + addModel(modelId, asString(entry.name) ?? undefined, reasoningEfforts); } } else { // Preserve previously-captured availableModels when the response only // updates currentModelId (e.g. a setModel response from some agents). const existing = this.sessionModelsMetadata.get(sessionId); for (const entry of existing?.availableModels ?? []) { - addModel(entry.modelId, entry.name); + addModel(entry.modelId, entry.name, entry.reasoningEfforts); } } diff --git a/cli/src/agent/sessionConfigRpc.test.ts b/cli/src/agent/sessionConfigRpc.test.ts index ef0d41a9..1e0ded85 100644 --- a/cli/src/agent/sessionConfigRpc.test.ts +++ b/cli/src/agent/sessionConfigRpc.test.ts @@ -91,6 +91,23 @@ describe('sessionConfigRpc', () => { expect(onApply).toHaveBeenCalledWith({ modelReasoningEffort: 'high' }) }) + it('applies nullable launch effort for Grok runtime switching', async () => { + const harness = createRpcHarness() + const onApply = vi.fn() + + registerSessionConfigRpc({ + rpcHandlerManager: harness.rpcHandlerManager, + flavor: 'grok', + effortMode: 'nullable', + onApply + }) + + const result = await harness.getHandler()({ effort: 'low' }) as { applied: Record } + + expect(result.applied.effort).toBe('low') + expect(onApply).toHaveBeenCalledWith({ effort: 'low' }) + }) + it('rejects model config for agents configured to reject model changes', async () => { const harness = createRpcHarness() diff --git a/cli/src/agent/sessionConfigRpc.ts b/cli/src/agent/sessionConfigRpc.ts index 6795e5e7..b6507e15 100644 --- a/cli/src/agent/sessionConfigRpc.ts +++ b/cli/src/agent/sessionConfigRpc.ts @@ -8,6 +8,7 @@ type SessionConfigState permissionMode?: TPermissionMode model?: string | null modelReasoningEffort?: string | null + effort?: string | null } type RegisterSessionConfigRpcOptions = { @@ -15,6 +16,7 @@ type RegisterSessionConfigRpcOptions Record onApply: (config: SessionConfigState) => void onAfterApply?: () => void @@ -58,6 +60,7 @@ export function registerSessionConfigRpc flavor, modelMode = 'reject', modelReasoningEffortMode = 'reject', + effortMode = 'reject', appliedFallback, onApply, onAfterApply @@ -67,7 +70,7 @@ export function registerSessionConfigRpc throw new Error('Invalid session config payload') } - const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown } + const config = payload as { permissionMode?: unknown; model?: unknown; modelReasoningEffort?: unknown; effort?: unknown } const applied: Record = {} const next: SessionConfigState = {} @@ -97,6 +100,16 @@ export function registerSessionConfigRpc } } + if (config.effort !== undefined) { + if (effortMode === 'reject') { + throw new Error('Invalid effort') + } + if (effortMode === 'nullable') { + next.effort = resolveNullableSessionModel(config.effort) + applied.effort = next.effort + } + } + onApply(next) onAfterApply?.() diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 246cc2a1..cf6fcb01 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -136,6 +136,7 @@ describe('bootstrapExistingSession', () => { codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', + grokSessionId: 'grok-thread-1', cursorSessionId: 'cursor-thread-1', cursorSessionProtocol: 'acp', summary: { @@ -164,6 +165,7 @@ describe('bootstrapExistingSession', () => { codexSessionId: 'codex-thread-1', geminiSessionId: 'gemini-thread-1', opencodeSessionId: 'opencode-thread-1', + grokSessionId: 'grok-thread-1', cursorSessionId: 'cursor-thread-1', cursorSessionProtocol: 'acp', summary: { @@ -176,12 +178,14 @@ describe('bootstrapExistingSession', () => { expect(sessionClient.updateMetadata).toHaveBeenCalledOnce() const updateHandler = sessionClient.updateMetadata.mock.calls[0][0] expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({ - codexSessionId: 'codex-thread-1' + codexSessionId: 'codex-thread-1', + grokSessionId: 'grok-thread-1' })) expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith( 'hapi-session-1', expect.objectContaining({ - codexSessionId: 'codex-thread-1' + codexSessionId: 'codex-thread-1', + grokSessionId: 'grok-thread-1' }) ) }) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 124ae124..f77a7728 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -97,6 +97,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId + if (metadata.grokSessionId !== undefined) preserved.grokSessionId = metadata.grokSessionId if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index 03d85431..5c8df7a7 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -71,6 +71,7 @@ export type PermissionResponse = export type AgentSessionModelDescriptor = { modelId: string; name?: string; + reasoningEfforts?: Array<{ value: string; name?: string; isDefault?: boolean }>; }; export type AgentSessionModelsMetadata = { diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index 4204c412..6253566e 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' const ioMock = vi.hoisted(() => vi.fn()) const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn()) +const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn()) vi.mock('socket.io-client', () => ({ io: ioMock @@ -18,6 +19,10 @@ vi.mock('../modules/common/opencodeModels', () => ({ listOpencodeModelsForCwd: listOpencodeModelsForCwdMock })) +vi.mock('../modules/common/grokModels', () => ({ + listGrokModelsForCwd: listGrokModelsForCwdMock +})) + import { ApiMachineClient, normalizeWindowsDriveRoot } from './apiMachine' import type { Machine } from './types' @@ -60,12 +65,22 @@ async function callListOpencodeModels(client: ApiMachineClient, machineId: strin return JSON.parse(raw) as unknown } +async function callListGrokModels(client: ApiMachineClient, machineId: string, cwd: string): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:listGrokModelsForCwd`, + params: JSON.stringify({ cwd }) + }) + return JSON.parse(raw) as unknown +} + describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { let workspaceRoot: string beforeEach(() => { ioMock.mockReset() listOpencodeModelsForCwdMock.mockReset() + listGrokModelsForCwdMock.mockReset() workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ws-')) }) @@ -157,6 +172,58 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => { }) }) +describe('ApiMachineClient listGrokModelsForCwd handler', () => { + let workspaceRoot: string + + beforeEach(() => { + ioMock.mockReset() + listGrokModelsForCwdMock.mockReset() + workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-grok-machine-ws-')) + }) + + afterEach(() => { + rmSync(workspaceRoot, { recursive: true, force: true }) + }) + + it('rejects cwd outside workspace roots before running grok models', async () => { + const machine = makeMachine('grok-machine-1') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-grok-outside-')) + + try { + expect(await callListGrokModels(client, machine.id, outsideCwd)).toEqual({ + success: false, + error: 'Path is outside workspace roots' + }) + expect(listGrokModelsForCwdMock).not.toHaveBeenCalled() + } finally { + rmSync(outsideCwd, { recursive: true, force: true }) + client.shutdown() + } + }) + + it('forwards a workspace cwd to the Grok model probe', async () => { + const machine = makeMachine('grok-machine-2') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + listGrokModelsForCwdMock.mockResolvedValueOnce({ + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + }) + + try { + expect(await callListGrokModels(client, machine.id, workspaceRoot)).toEqual({ + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + }) + expect(listGrokModelsForCwdMock).toHaveBeenCalledWith(realpathSync.native(workspaceRoot)) + } finally { + client.shutdown() + } + }) +}) + describe('ApiMachineClient keepAlive lifecycle', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 07e24153..f0e67bd2 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -22,6 +22,11 @@ import { type ListOpencodeModelsForCwdRequest, type ListOpencodeModelsForCwdResponse } from '../modules/common/opencodeModels' +import { + listGrokModelsForCwd, + type ListGrokModelsForCwdRequest, + type ListGrokModelsForCwdResponse +} from '../modules/common/grokModels' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' @@ -216,6 +221,21 @@ export class ApiMachineClient { return await listOpencodeModelsForCwd(resolvedCwd) } ) + + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListGrokModelsForCwd, + async (params) => { + const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : '' + if (!rawCwd) return { success: false, error: 'cwd is required' } + + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + + return await listGrokModelsForCwd(resolvedCwd) + } + ) } private isWithinWorkspaceRoots(absolutePath: string): boolean { diff --git a/cli/src/commands/grok.ts b/cli/src/commands/grok.ts new file mode 100644 index 00000000..7edbd7b6 --- /dev/null +++ b/cli/src/commands/grok.ts @@ -0,0 +1,34 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' +import { GROK_PERMISSION_MODES } from '@hapi/protocol/modes' +import { parseRemoteAgentCommandOptions } from './agentCommandOptions' + +export const grokCommand: CommandDefinition = { + name: 'grok', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const hasExplicitPermissionMode = commandArgs.includes('--permission-mode') + const normalizedArgs = hasExplicitPermissionMode + ? commandArgs + : commandArgs.flatMap((arg) => arg === '--yolo' + ? ['--permission-mode', 'bypassPermissions'] + : [arg]) + const options = parseRemoteAgentCommandOptions(normalizedArgs, GROK_PERMISSION_MODES) + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runGrok } = await import('@/grok/runGrok') + await runGrok(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) console.error(error) + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 358281df..9d3b7d24 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -8,6 +8,7 @@ import { runnerCommand } from './runner' import { resumeCommand } from './resume' import { doctorCommand } from './doctor' import { kimiCommand } from './kimi' +import { grokCommand } from './grok' import { opencodeCommand } from './opencode' import { piCommand } from './pi' import { hookForwarderCommand } from './hookForwarder' @@ -38,6 +39,7 @@ const COMMANDS: CommandDefinition[] = [ codexCommand, cursorCommand, removedGeminiCommand, + grokCommand, kimiCommand, opencodeCommand, piCommand, diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index 4c3c709f..c97553d0 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -10,6 +10,7 @@ const { renderMock, runCodexMock, runClaudeMock, + runGrokMock, runPiMock, assertCodexLocalSupportedMock, existsSyncMock @@ -23,6 +24,7 @@ const { renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), + runGrokMock: vi.fn(async () => {}), runPiMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), existsSyncMock: vi.fn(() => true) @@ -46,6 +48,7 @@ vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ })) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) +vi.mock('@/grok/runGrok', () => ({ runGrok: runGrokMock })) vi.mock('@/pi/runPi', () => ({ runPi: runPiMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) @@ -75,6 +78,7 @@ describe('resumeCommand', () => { }) runCodexMock.mockClear() runClaudeMock.mockClear() + runGrokMock.mockClear() runPiMock.mockClear() assertCodexLocalSupportedMock.mockClear() existsSyncMock.mockReturnValue(true) @@ -142,6 +146,35 @@ describe('resumeCommand', () => { }) }) + it('resumes a Grok target in the native local TUI', async () => { + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'hapi-session-grok', + flavor: 'grok', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'grok-session-1', + model: 'grok-4.5', + effort: 'low', + permissionMode: 'plan' + }) + + await resumeCommand.run(createContext(['hapi-session-grok'])) + + expect(runGrokMock).toHaveBeenCalledWith({ + existingSessionId: 'hapi-session-grok', + workingDirectory: '/tmp/project', + resumeSessionId: 'grok-session-1', + startedBy: 'terminal', + permissionMode: 'plan', + startingMode: 'local', + model: 'grok-4.5', + effort: 'low' + }) + }) + it('rejects an active Gemini target before handoff (no longer supported, leaves running session alone)', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index 146189d6..50b18223 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -7,6 +7,7 @@ import type { ClaudePermissionMode, CodexPermissionMode, CursorPermissionMode, + GrokPermissionMode, KimiPermissionMode, OpencodePermissionMode } from '@hapi/protocol/types' @@ -120,6 +121,21 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { return } + if (target.flavor === 'grok') { + const { runGrok } = await import('@/grok/runGrok') + await runGrok({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + permissionMode: base.permissionMode as GrokPermissionMode | undefined, + startingMode: 'local', + model: target.model ?? undefined, + effort: target.effort ?? undefined + }) + return + } + if (target.flavor === 'kimi') { const { runKimi } = await import('@/kimi/runKimi') await runKimi({ diff --git a/cli/src/grok/grokLocal.test.ts b/cli/src/grok/grokLocal.test.ts new file mode 100644 index 00000000..73706f3c --- /dev/null +++ b/cli/src/grok/grokLocal.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { buildGrokLocalArgs } from './grokLocal' + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function setWindowsPlatform(): void { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) +} + +describe('buildGrokLocalArgs', () => { + it('uses a caller-supplied id for a new native session', () => { + expect(buildGrokLocalArgs({ + sessionId: '11111111-1111-4111-8111-111111111111', + resume: false + })).toEqual([ + '--session-id', '11111111-1111-4111-8111-111111111111' + ]) + }) + + it('resumes with low effort and plan mode using official Grok flags', () => { + expect(buildGrokLocalArgs({ + sessionId: 'grok-session-1', + resume: true, + model: 'grok-4.5', + effort: 'low', + permissionMode: 'plan' + })).toEqual([ + '--resume', 'grok-session-1', + '--model', 'grok-4.5', + '--reasoning-effort', 'low', + '--permission-mode', 'plan' + ]) + }) + + it('rejects shell metacharacters in dynamic Windows arguments', () => { + setWindowsPlatform() + + expect(() => buildGrokLocalArgs({ + sessionId: 'session&whoami', + resume: true + })).toThrow('Invalid sessionId') + expect(() => buildGrokLocalArgs({ + sessionId: 'session-1', + resume: true, + model: 'grok|whoami' + })).toThrow('Invalid model') + expect(() => buildGrokLocalArgs({ + sessionId: 'session-1', + resume: true, + effort: 'low%PATH%' + })).toThrow('Invalid effort') + }) +}) diff --git a/cli/src/grok/grokLocal.ts b/cli/src/grok/grokLocal.ts new file mode 100644 index 00000000..0f5b853e --- /dev/null +++ b/cli/src/grok/grokLocal.ts @@ -0,0 +1,59 @@ +import { logger } from '@/ui/logger' +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard' +import type { PermissionMode } from './types' +import { assertSafeWindowsShellArg } from './utils/windowsShellArgs' + +type GrokLocalOptions = { + sessionId: string + resume: boolean + model?: string + effort?: string + permissionMode?: PermissionMode +} + +export function buildGrokLocalArgs(opts: GrokLocalOptions): string[] { + assertSafeWindowsShellArg(opts.sessionId, 'sessionId') + if (opts.model) assertSafeWindowsShellArg(opts.model, 'model') + if (opts.effort) assertSafeWindowsShellArg(opts.effort, 'effort') + + const args: string[] = [] + + if (opts.resume) { + args.push('--resume', opts.sessionId) + } else { + args.push('--session-id', opts.sessionId) + } + if (opts.model) { + args.push('--model', opts.model) + } + if (opts.effort) { + args.push('--reasoning-effort', opts.effort) + } + if (opts.permissionMode && opts.permissionMode !== 'default') { + args.push('--permission-mode', opts.permissionMode) + } + return args +} + +export async function grokLocal(opts: GrokLocalOptions & { + path: string + abort: AbortSignal +}): Promise { + const args = buildGrokLocalArgs(opts) + + logger.debug(`[GrokLocal] Spawning grok with args: ${JSON.stringify(args)}`) + + await spawnWithTerminalGuard({ + command: 'grok', + args, + cwd: opts.path, + env: process.env, + signal: opts.abort, + shell: process.platform === 'win32', + logLabel: 'GrokLocal', + spawnName: 'grok', + installHint: 'Grok Build CLI (https://docs.x.ai/build/overview)', + includeCause: true, + logExit: true + }) +} diff --git a/cli/src/grok/grokLocalLauncher.ts b/cli/src/grok/grokLocalLauncher.ts new file mode 100644 index 00000000..c2b27fab --- /dev/null +++ b/cli/src/grok/grokLocalLauncher.ts @@ -0,0 +1,42 @@ +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher' +import { grokLocal } from './grokLocal' +import type { GrokSession } from './session' +import type { PermissionMode } from './types' + +export async function grokLocalLauncher( + session: GrokSession, + opts: { model?: string; effort?: string } +): Promise<'switch' | 'exit'> { + const launcher = new BaseLocalLauncher({ + label: 'grok-local', + failureLabel: 'Local Grok process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + if (!session.sessionId) { + throw new Error('Grok session id is missing') + } + const resume = session.shouldResumeNativeSession() + session.markNativeSessionStarted() + await grokLocal({ + path: session.path, + sessionId: session.sessionId, + resume, + abort: abortSignal, + model: opts.model, + effort: opts.effort, + permissionMode: session.getPermissionMode() as PermissionMode | undefined + }) + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }) + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason) + } + }) + + return await launcher.run() +} diff --git a/cli/src/grok/grokRemoteLauncher.test.ts b/cli/src/grok/grokRemoteLauncher.test.ts new file mode 100644 index 00000000..726d00fb --- /dev/null +++ b/cli/src/grok/grokRemoteLauncher.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import type { GrokMode } from './types' + +const harness = vi.hoisted(() => ({ + setModels: [] as Array<{ sessionId: string; modelId: string; flavor?: string }>, + setModes: [] as Array<{ sessionId: string; modeId: string }>, + prompts: [] as unknown[][], + autoCommandAvailable: true, + stderrHandler: null as null | ((error: { message: string; raw: string }) => void), + sessionInfoUpdateHandler: null as null | ((update: { title?: string | null }) => void), + nativeTitle: null as string | null, + nativeTitleSent: false, +})) + +vi.mock('./utils/grokBackend', () => ({ + createGrokBackend: vi.fn(() => ({ + initialize: vi.fn(async () => {}), + newSession: vi.fn(async () => 'grok-session-1'), + loadSession: vi.fn(async () => 'grok-session-1'), + setModel: vi.fn(async (sessionId: string, modelId: string, opts?: { flavor?: string }) => { + harness.setModels.push({ sessionId, modelId, flavor: opts?.flavor }) + }), + setMode: vi.fn(async (sessionId: string, modeId: string) => { + harness.setModes.push({ sessionId, modeId }) + }), + prompt: vi.fn(async (_sessionId: string, content: unknown[]) => { + harness.prompts.push(content) + if (harness.nativeTitle !== null && !harness.nativeTitleSent) { + harness.nativeTitleSent = true + harness.sessionInfoUpdateHandler?.({ title: harness.nativeTitle }) + } + if (harness.prompts.length === 1) { + harness.stderrHandler?.({ + message: 'status=402 Payment Required model_id=grok-build spending-limit', + raw: 'status=402 Payment Required model_id=grok-build spending-limit' + }) + } + }), + cancelPrompt: vi.fn(async () => {}), + respondToPermission: vi.fn(async () => {}), + onStderrError: vi.fn((handler) => { harness.stderrHandler = handler }), + setSessionInfoUpdateListener: vi.fn((handler) => { harness.sessionInfoUpdateHandler = handler }), + onPermissionRequest: vi.fn(), + disconnect: vi.fn(async () => {}), + getSessionModelsMetadata: vi.fn(() => ({ + availableModels: [{ modelId: 'grok-a' }, { modelId: 'grok-b' }], + currentModelId: 'grok-a' + })), + getThoughtLevelConfigOption: vi.fn(() => ({ + id: 'x.ai/reasoning-effort', + currentValue: 'low', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'high' }] + })), + hasAvailableCommand: vi.fn((_sessionId: string, command: string) => ( + command === 'auto' && harness.autoCommandAvailable + )) + })), + formatGrokError: (error: unknown) => error instanceof Error ? error.message : String(error), + isGrokBuildAuxiliaryQuotaError: (value: string, activeModel?: string | null) => ( + activeModel !== 'grok-build' + && value.includes('402 Payment Required') + && value.includes('model_id=grok-build') + ) +})) + +vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ + buildHapiMcpBridge: async () => ({ server: { stop: () => {} }, mcpServers: {} }) +})) +vi.mock('./utils/permissionHandler', () => ({ + GrokPermissionHandler: class { async cancelAll(): Promise {} } +})) +vi.mock('@/ui/ink/GrokDisplay', () => ({ GrokDisplay: () => null })) +vi.mock('@/ui/logger', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), info: vi.fn() } +})) + +import { grokRemoteLauncher } from './grokRemoteLauncher' + +function createSession() { + const queue = new MessageQueue2((mode) => JSON.stringify(mode)) + queue.pushIsolateAndClear('first', { permissionMode: 'default', model: 'grok-a', effort: 'low' }) + queue.push('second', { permissionMode: 'default', model: 'grok-b', effort: 'medium' }) + queue.close() + const rpcHandlers = new Map unknown>() + const session = { + path: '/tmp/grok-test', + logPath: '/tmp/grok-test/test.log', + client: { + rpcHandlerManager: { + registerHandler(method: string, handler: () => unknown) { rpcHandlers.set(method, handler) } + }, + sendAgentMessage: vi.fn(), + sendSessionEvent: vi.fn(), + sendClaudeSessionMessage: vi.fn() + }, + queue, + sessionId: null as string | null, + thinking: false, + getPermissionMode: () => 'default' as const, + registerExistingNativeSession(id: string) { session.sessionId = id }, + setModel: vi.fn(), + setEffort: vi.fn(), + setPermissionMode: vi.fn(), + pushKeepAlive: vi.fn(), + onThinkingChange(thinking: boolean) { session.thinking = thinking }, + sendAgentMessage: vi.fn(), + sendSessionEvent: vi.fn() + } + return { session, rpcHandlers } +} + +function createPermissionSession(modes: GrokMode['permissionMode'][]) { + const { session, rpcHandlers } = createSession() + session.queue.reset() + modes.forEach((permissionMode, index) => { + session.queue.push(`permission-${index + 1}`, { + permissionMode, + model: 'grok-a', + effort: 'low' + }) + }) + session.queue.close() + return { session, rpcHandlers } +} + +describe('grokRemoteLauncher runtime config', () => { + afterEach(() => { + harness.setModels = [] + harness.setModes = [] + harness.prompts = [] + harness.stderrHandler = null + harness.sessionInfoUpdateHandler = null + harness.nativeTitle = null + harness.nativeTitleSent = false + harness.autoCommandAvailable = true + }) + + it('switches model and effort between turns and exposes session catalogs', async () => { + const { session, rpcHandlers } = createSession() + const discovered: unknown[] = [] + + await grokRemoteLauncher(session as never, { + model: 'grok-a', + effort: 'low', + onConfigDiscovered: (config) => discovered.push(config) + }) + + expect(discovered).toEqual([{ model: 'grok-a', effort: 'low' }]) + expect(harness.setModels).toEqual([ + { sessionId: 'grok-session-1', modelId: 'grok-b', flavor: 'grok' } + ]) + expect(harness.setModes).toEqual([ + { sessionId: 'grok-session-1', modeId: 'medium' } + ]) + expect(harness.prompts).toHaveLength(3) + expect(session.sendSessionEvent).not.toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('402 Payment Required') + })) + expect(JSON.stringify(harness.prompts[0])).toContain('/always-approve off') + expect(JSON.stringify(harness.prompts[1])).toContain('hapi_change_title') + expect(JSON.stringify(harness.prompts[2])).not.toContain('hapi_change_title') + expect(await rpcHandlers.get('listGrokModels')?.()).toMatchObject({ success: true, currentModelId: 'grok-a' }) + expect(await rpcHandlers.get('listGrokReasoningEffortOptions')?.()).toMatchObject({ success: true, currentValue: 'low' }) + }) + + it('uses Grok slash commands to enter and leave Auto permission mode without model turns', async () => { + const { session } = createPermissionSession(['auto', 'default']) + + await grokRemoteLauncher(session as never, { model: 'grok-a', effort: 'low' }) + + expect(harness.prompts.map((prompt) => JSON.stringify(prompt))).toEqual([ + expect.stringContaining('/auto'), + expect.stringContaining('permission-1'), + expect.stringContaining('/always-approve off'), + expect.stringContaining('permission-2') + ]) + }) + + it('forwards ACP native titles while retaining the prompt fallback', async () => { + harness.nativeTitle = 'Native Grok title' + const { session } = createSession() + + await grokRemoteLauncher(session as never, { model: 'grok-a', effort: 'low' }) + + expect(session.client.sendClaudeSessionMessage).toHaveBeenCalledWith({ + type: 'summary', + summary: 'Native Grok title', + leafUuid: expect.any(String) + }) + expect(JSON.stringify(harness.prompts[1])).toContain('hapi_change_title') + }) + + it('rolls Auto back to Default when Grok does not advertise the feature', async () => { + harness.autoCommandAvailable = false + const { session } = createPermissionSession(['auto']) + const rollbacks: string[] = [] + + await grokRemoteLauncher(session as never, { + model: 'grok-a', + effort: 'low', + onPermissionModeRollback: (mode) => rollbacks.push(mode) + }) + + expect(rollbacks).toEqual(['default']) + expect(harness.prompts).toHaveLength(2) + expect(JSON.stringify(harness.prompts[0])).toContain('/always-approve off') + expect(session.sendSessionEvent).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('not enabled') + })) + }) +}) diff --git a/cli/src/grok/grokRemoteLauncher.ts b/cli/src/grok/grokRemoteLauncher.ts new file mode 100644 index 00000000..f1322676 --- /dev/null +++ b/cli/src/grok/grokRemoteLauncher.ts @@ -0,0 +1,432 @@ +import { randomUUID } from 'node:crypto' +import React from 'react' +import { logger } from '@/ui/logger' +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge' +import { convertAgentMessage } from '@/agent/messageConverter' +import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types' +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from '@/modules/common/remote/RemoteLauncherBase' +import { GrokDisplay } from '@/ui/ink/GrokDisplay' +import type { GrokSession } from './session' +import type { PermissionMode } from './types' +import { + createGrokBackend, + formatGrokError, + isGrokBuildAuxiliaryQuotaError +} from './utils/grokBackend' +import { GrokPermissionHandler } from './utils/permissionHandler' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { GROK_TITLE_INSTRUCTION } from './utils/systemPrompt' + +const PLAN_MODE_INSTRUCTION = + 'Work in plan-only mode. Analyze and propose a plan, but do not execute commands or modify files.' + +type GrokRemoteLauncherOptions = { + model?: string + effort?: string + onModelRollback?: (model: string | null) => void + onEffortRollback?: (effort: string | null) => void + onPermissionModeRollback?: (mode: PermissionMode) => void + onConfigDiscovered?: (config: { model: string | null; effort: string | null }) => void +} + +class GrokRemoteLauncher extends RemoteLauncherBase { + private backend: ReturnType | null = null + private permissionHandler: GrokPermissionHandler | null = null + private happyServer: { stop: () => void } | null = null + private abortController = new AbortController() + private displayPermissionMode: PermissionMode | null = null + private readonly lastDisplayedToolCall = new Map() + private currentBackendModel: string | null = null + private defaultBackendModel: string | null = null + private currentBackendEffort: string | null = null + private defaultBackendEffort: string | null = null + private currentBackendPermissionMode: 'default' | 'auto' | null = null + private instructionsSent = false + + constructor( + private readonly session: GrokSession, + private readonly opts: GrokRemoteLauncherOptions + ) { + super(process.env.DEBUG ? session.logPath : undefined) + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }) + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(GrokDisplay, context) + } + + protected async runMainLoop(): Promise { + const session = this.session + const { server, mcpServers } = await buildHapiMcpBridge(session.client) + this.happyServer = server + + const backend = createGrokBackend({ + cwd: session.path, + ...this.opts + }) + this.backend = backend + backend.setSessionInfoUpdateListener(({ title }) => { + if (typeof title !== 'string') return + session.client.sendClaudeSessionMessage({ + type: 'summary', + summary: title, + leafUuid: randomUUID() + }) + }) + backend.onStderrError((error) => { + const activeModel = this.currentBackendModel ?? this.opts.model ?? null + if (isGrokBuildAuxiliaryQuotaError(`${error.message}\n${error.raw}`, activeModel)) { + logger.debug('[grok-remote] Suppressed non-fatal grok-build session-title quota error', { + activeModel + }) + return + } + logger.debug('[grok-remote] stderr error', error) + const message = formatGrokError(error.message) + session.sendSessionEvent({ type: 'message', message }) + this.messageBuffer.addMessage(message, 'status') + }) + + await backend.initialize() + + const acpMcpServers = toAcpMcpServers(mcpServers) + let acpSessionId: string + try { + if (session.sessionId) { + try { + acpSessionId = await backend.loadSession({ + sessionId: session.sessionId, + cwd: session.path, + mcpServers: acpMcpServers + }) + } catch (error) { + logger.warn('[grok-remote] resume failed, starting new session', error) + session.sendSessionEvent({ + type: 'message', + message: 'Grok resume failed; starting a new session.' + }) + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }) + } + } else { + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }) + } + } catch (error) { + const message = formatGrokError(error) + session.sendSessionEvent({ type: 'message', message }) + throw new Error(message, { cause: error }) + } + + session.registerExistingNativeSession(acpSessionId) + const modelMetadata = backend.getSessionModelsMetadata(acpSessionId) + const effortMetadata = backend.getThoughtLevelConfigOption(acpSessionId) + this.currentBackendModel = modelMetadata?.currentModelId ?? this.opts.model ?? null + this.defaultBackendModel = this.currentBackendModel + this.currentBackendEffort = effortMetadata?.currentValue ?? this.opts.effort ?? null + this.defaultBackendEffort = this.currentBackendEffort + this.opts.onConfigDiscovered?.({ + model: this.currentBackendModel, + effort: this.currentBackendEffort + }) + + session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListGrokModels, async () => { + const metadata = backend.getSessionModelsMetadata(acpSessionId) + if (!metadata) return { success: false, error: 'Grok model metadata is not available' } + return { + success: true, + availableModels: metadata.availableModels, + currentModelId: metadata.currentModelId, + autoPermissionModeSupported: backend.hasAvailableCommand(acpSessionId, 'auto') + } + }) + session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListGrokReasoningEffortOptions, async () => { + const metadata = backend.getThoughtLevelConfigOption(acpSessionId) + if (!metadata) return { success: false, error: 'Grok effort metadata is not available' } + return { + success: true, + options: metadata.options, + currentValue: metadata.currentValue ?? null + } + }) + this.permissionHandler = new GrokPermissionHandler( + session.client, + backend, + () => session.getPermissionMode() as PermissionMode | undefined + ) + this.applyDisplayMode(session.getPermissionMode() as PermissionMode | undefined) + this.messageBuffer.addMessage(`[MODEL:${this.currentBackendModel ?? 'default'}]`, 'system') + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }) + + while (!this.shouldExit) { + const waitSignal = this.abortController.signal + const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal) + if (!batch) { + if (waitSignal.aborted && !this.shouldExit) continue + break + } + + const requestedModel = batch.mode.model === null + ? this.defaultBackendModel + : batch.mode.model + if (requestedModel && requestedModel !== this.currentBackendModel) { + try { + await backend.setModel(acpSessionId, requestedModel, { flavor: 'grok' }) + this.currentBackendModel = requestedModel + batch.mode.model = requestedModel + } catch (error) { + logger.warn('[grok-remote] Inline model switch failed', error) + this.rollbackModel(batch, this.currentBackendModel) + session.sendSessionEvent({ + type: 'message', + message: `Failed to switch Grok model to ${requestedModel}.` + }) + } + } + + const requestedEffort = batch.mode.effort === null + ? this.defaultBackendEffort + : batch.mode.effort + if (requestedEffort && requestedEffort !== this.currentBackendEffort) { + try { + await backend.setMode(acpSessionId, requestedEffort) + this.currentBackendEffort = requestedEffort + batch.mode.effort = requestedEffort + } catch (error) { + logger.warn('[grok-remote] Inline effort switch failed', error) + this.rollbackEffort(batch, this.currentBackendEffort) + session.sendSessionEvent({ + type: 'message', + message: `Failed to switch Grok effort to ${requestedEffort}.` + }) + } + } + + const requestedPermissionMode = batch.mode.permissionMode + try { + await this.syncBackendPermissionMode(acpSessionId, requestedPermissionMode) + } catch (error) { + logger.warn('[grok-remote] Inline permission mode switch failed', error) + const fallbackMode: PermissionMode = this.currentBackendPermissionMode ?? 'default' + this.rollbackPermissionMode(batch, fallbackMode) + session.sendSessionEvent({ + type: 'message', + message: requestedPermissionMode === 'auto' + ? 'Grok Auto permission mode is not enabled for this account or CLI build; using Default.' + : `Failed to switch Grok permission mode to ${requestedPermissionMode}.` + }) + } + + this.applyDisplayMode(batch.mode.permissionMode) + this.messageBuffer.addMessage(batch.message, 'user') + const isSlashCommand = batch.message.trimStart().startsWith('/') + let text = batch.mode.permissionMode === 'plan' && !isSlashCommand + ? `${PLAN_MODE_INSTRUCTION}\n\n${batch.message}` + : batch.message + if (!this.instructionsSent && !isSlashCommand) { + text = `${GROK_TITLE_INSTRUCTION}\n\n${text}` + this.instructionsSent = true + } + const promptContent: PromptContent[] = [{ type: 'text', text }] + + session.onThinkingChange(true) + try { + await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message) + }) + } catch (error) { + const message = formatGrokError(error) + logger.warn('[grok-remote] prompt failed', error) + session.sendSessionEvent({ type: 'message', message: `Grok prompt failed: ${message}` }) + this.messageBuffer.addMessage(`Grok prompt failed: ${message}`, 'status') + } finally { + session.onThinkingChange(false) + await this.permissionHandler?.cancelAll('Prompt finished') + if (session.queue.size() === 0 && !this.shouldExit) { + session.sendSessionEvent({ type: 'ready' }) + } + } + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager) + if (this.permissionHandler) { + await this.permissionHandler.cancelAll('Session ended') + this.permissionHandler = null + } + if (this.backend) { + await this.backend.disconnect() + this.backend = null + } + if (this.happyServer) { + this.happyServer.stop() + this.happyServer = null + } + } + + private handleAgentMessage(message: AgentMessage): void { + const converted = convertAgentMessage(message) + if (converted) this.session.sendAgentMessage(converted) + + switch (message.type) { + case 'text': + this.messageBuffer.addMessage(message.text, 'assistant') + break + case 'reasoning': + if (!message.live) { + this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system') + } + break + case 'tool_call': { + const previous = this.lastDisplayedToolCall.get(message.id) + if (previous !== message.name) { + this.lastDisplayedToolCall.set(message.id, message.name) + this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool') + } + break + } + case 'tool_result': + this.messageBuffer.addMessage('Tool result received', 'result') + break + case 'plan': + this.messageBuffer.addMessage('Plan updated', 'status') + break + case 'error': + this.messageBuffer.addMessage(message.message, 'status') + break + case 'turn_complete': + this.messageBuffer.addMessage('Turn complete', 'status') + break + case 'usage': + break + default: { + const exhaustive: never = message + return exhaustive + } + } + } + + private applyDisplayMode(permissionMode: PermissionMode | undefined): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system') + } + } + + private rollbackModel(batch: { mode: { model?: string | null } }, model: string | null): void { + batch.mode.model = model + this.session.setModel(model) + this.session.pushKeepAlive() + this.opts.onModelRollback?.(model) + } + + private rollbackEffort(batch: { mode: { effort?: string | null } }, effort: string | null): void { + batch.mode.effort = effort + this.session.setEffort(effort) + this.session.pushKeepAlive() + this.opts.onEffortRollback?.(effort) + } + + private rollbackPermissionMode( + batch: { mode: { permissionMode: PermissionMode } }, + permissionMode: PermissionMode + ): void { + batch.mode.permissionMode = permissionMode + this.session.setPermissionMode(permissionMode) + this.session.pushKeepAlive() + this.opts.onPermissionModeRollback?.(permissionMode) + } + + private async syncBackendPermissionMode( + sessionId: string, + permissionMode: PermissionMode + ): Promise { + if (!this.backend) return + + if (permissionMode === 'auto') { + if (this.currentBackendPermissionMode === 'auto') return + if (!this.backend.hasAvailableCommand(sessionId, 'auto')) { + await this.setBackendDefaultPermissionMode(sessionId) + throw new Error('Grok did not advertise the /auto command') + } + await this.backend.prompt( + sessionId, + [{ type: 'text', text: '/auto' }], + () => undefined + ) + this.currentBackendPermissionMode = 'auto' + return + } + + await this.setBackendDefaultPermissionMode(sessionId) + } + + private async setBackendDefaultPermissionMode(sessionId: string): Promise { + if (!this.backend || this.currentBackendPermissionMode === 'default') return + await this.backend.prompt( + sessionId, + [{ type: 'text', text: '/always-approve off' }], + () => undefined + ) + this.currentBackendPermissionMode = 'default' + } + + private async handleAbort(): Promise { + if (this.backend && this.session.sessionId) { + await this.backend.cancelPrompt(this.session.sessionId) + } + await this.permissionHandler?.cancelAll('User aborted') + this.session.queue.reset() + this.session.onThinkingChange(false) + this.abortController.abort() + this.abortController = new AbortController() + this.messageBuffer.addMessage('Turn aborted', 'status') + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()) + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()) + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()) + } +} + +function toAcpMcpServers(config: Record): McpServerStdio[] { + return Object.entries(config).map(([name, entry]) => ({ + name, + command: entry.command, + args: entry.args, + env: [] + })) +} + +export async function grokRemoteLauncher( + session: GrokSession, + opts: GrokRemoteLauncherOptions +): Promise<'switch' | 'exit'> { + return new GrokRemoteLauncher(session, opts).launch() +} diff --git a/cli/src/grok/loop.ts b/cli/src/grok/loop.ts new file mode 100644 index 00000000..f528b6d9 --- /dev/null +++ b/cli/src/grok/loop.ts @@ -0,0 +1,74 @@ +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { logger } from '@/ui/logger' +import { runLocalRemoteSession } from '@/agent/loopBase' +import { GrokSession } from './session' +import { grokLocalLauncher } from './grokLocalLauncher' +import { grokRemoteLauncher } from './grokRemoteLauncher' +import { ApiClient, ApiSessionClient } from '@/lib' +import type { GrokMode, PermissionMode } from './types' + +interface GrokLoopOptions { + path: string + hapiSessionId: string + startingMode: 'local' | 'remote' + startedBy: 'runner' | 'terminal' + onModeChange: (mode: 'local' | 'remote') => void + messageQueue: MessageQueue2 + session: ApiSessionClient + api: ApiClient + permissionMode: PermissionMode + model?: string + effort?: string + resumeSessionId?: string + onSessionReady?: (session: GrokSession) => void + onModelRollback?: (model: string | null) => void + onEffortRollback?: (effort: string | null) => void + onPermissionModeRollback?: (mode: PermissionMode) => void + onConfigDiscovered?: (config: { model: string | null; effort: string | null }) => void +} + +export async function grokLoop(opts: GrokLoopOptions): Promise { + const session = new GrokSession({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: opts.resumeSessionId ?? null, + logPath: logger.getLogPath(), + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.startingMode, + startedBy: opts.startedBy, + startingMode: opts.startingMode, + permissionMode: opts.permissionMode, + model: opts.model ?? null, + effort: opts.effort ?? null + }) + + if (opts.resumeSessionId) { + session.registerExistingNativeSession(opts.resumeSessionId) + } else if (opts.startingMode === 'local') { + // Grok accepts a caller-supplied UUID for a new native session. Reusing + // the HAPI session UUID gives local mode a deterministic resume token + // without scraping the fullscreen TUI. + session.registerPendingNativeSession(opts.hapiSessionId) + } + + await runLocalRemoteSession({ + session, + startingMode: opts.startingMode, + logTag: 'grok-loop', + runLocal: (instance) => grokLocalLauncher(instance, { + model: instance.getModel() ?? undefined, + effort: instance.getEffort() ?? undefined + }), + runRemote: (instance) => grokRemoteLauncher(instance, { + model: instance.getModel() ?? undefined, + effort: instance.getEffort() ?? undefined, + onModelRollback: opts.onModelRollback, + onEffortRollback: opts.onEffortRollback, + onPermissionModeRollback: opts.onPermissionModeRollback, + onConfigDiscovered: opts.onConfigDiscovered + }), + onSessionReady: opts.onSessionReady + }) +} diff --git a/cli/src/grok/runGrok.ts b/cli/src/grok/runGrok.ts new file mode 100644 index 00000000..881002d7 --- /dev/null +++ b/cli/src/grok/runGrok.ts @@ -0,0 +1,159 @@ +import { logger } from '@/ui/logger' +import { grokLoop } from './loop' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { hashObject } from '@/utils/deterministicJson' +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler' +import type { AgentState } from '@/api/types' +import type { GrokSession } from './session' +import type { GrokMode, PermissionMode } from './types' +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory' +import { registerLocalHandoffHandler } from '@/agent/localHandoff' +import { + createModeChangeHandler, + createRunnerLifecycle, + setControlledByUser +} from '@/agent/runnerLifecycle' +import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc' +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter' +import { getInvokedCwd } from '@/utils/invokedCwd' + +export async function runGrok(opts: { + startedBy?: 'runner' | 'terminal' + startingMode?: 'local' | 'remote' + permissionMode?: PermissionMode + model?: string + effort?: string + resumeSessionId?: string + existingSessionId?: string + workingDirectory?: string +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd() + const startedBy = opts.startedBy ?? 'terminal' + const startingMode: 'local' | 'remote' = opts.startingMode + ?? (startedBy === 'runner' ? 'remote' : 'local') + + logger.debug(`[grok] Starting with options: startedBy=${startedBy}, startingMode=${startingMode}`) + + const initialState: AgentState = { controlledByUser: false } + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'grok', + startedBy, + workingDirectory + }) + : await bootstrapSession({ + flavor: 'grok', + startedBy, + workingDirectory, + agentState: initialState, + model: opts.model, + effort: opts.effort + }) + const { api, session, sessionInfo } = bootstrap + setControlledByUser(session, startingMode) + + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default' + let currentModel = opts.model ?? null + let currentEffort = opts.effort ?? null + const queue = new MessageQueue2((mode) => hashObject(mode)) + const sessionRef: { current: GrokSession | null } = { current: null } + + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'grok', + stopKeepAlive: () => sessionRef.current?.stopKeepAlive() + }) + lifecycle.registerProcessHandlers() + registerKillSessionHandler(session.rpcHandlerManager, lifecycle) + registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle) + + const syncSessionMode = () => { + const active = sessionRef.current + if (!active) return + active.setPermissionMode(currentPermissionMode) + active.setModel(currentModel) + active.setEffort(currentEffort) + active.pushKeepAlive() + } + + session.onUserMessage((message, localId) => { + queue.push( + formatMessageWithAttachments(message.content.text, message.content.attachments), + { + permissionMode: currentPermissionMode, + model: currentModel ?? undefined, + effort: currentEffort ?? undefined + }, + localId + ) + }) + session.onCancelQueuedMessage((localId) => queue.cancelByLocalId(localId)) + + registerSessionConfigRpc({ + rpcHandlerManager: session.rpcHandlerManager, + flavor: 'grok', + modelMode: 'nullable', + modelReasoningEffortMode: 'ignore', + effortMode: 'nullable', + onApply: (config) => { + if (config.permissionMode !== undefined) { + currentPermissionMode = config.permissionMode + } + if (config.model !== undefined) currentModel = config.model + if (config.effort !== undefined) currentEffort = config.effort + }, + onAfterApply: syncSessionMode, + appliedFallback: () => ({ permissionMode: currentPermissionMode }) + }) + + let crashed = false + try { + await grokLoop({ + path: workingDirectory, + hapiSessionId: sessionInfo.id, + startingMode, + startedBy, + messageQueue: queue, + session, + api, + permissionMode: currentPermissionMode, + model: opts.model, + effort: opts.effort, + resumeSessionId: opts.resumeSessionId, + onModelRollback: (model) => { + currentModel = model + }, + onEffortRollback: (effort) => { + currentEffort = effort + }, + onPermissionModeRollback: (permissionMode) => { + currentPermissionMode = permissionMode + }, + onConfigDiscovered: (config) => { + currentModel = config.model + currentEffort = config.effort + syncSessionMode() + }, + onModeChange: createModeChangeHandler(session), + onSessionReady: (instance) => { + sessionRef.current = instance + syncSessionMode() + } + }) + } catch (error) { + crashed = true + lifecycle.markCrash(error) + logger.debug('[grok] Loop error:', error) + } finally { + const localFailure = sessionRef.current?.localLaunchFailure + if (localFailure?.exitReason === 'exit') { + lifecycle.setExitCode(1) + lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`) + lifecycle.setSessionEndReason('error') + } else if (!crashed) { + lifecycle.setSessionEndReason('completed') + } + await lifecycle.cleanupAndExit() + } +} diff --git a/cli/src/grok/session.ts b/cli/src/grok/session.ts new file mode 100644 index 00000000..c8168c6a --- /dev/null +++ b/cli/src/grok/session.ts @@ -0,0 +1,97 @@ +import { ApiClient, ApiSessionClient } from '@/lib' +import { MessageQueue2 } from '@/utils/MessageQueue2' +import { AgentSessionBase } from '@/agent/sessionBase' +import type { GrokMode, PermissionMode } from './types' +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy' + +type LocalLaunchFailure = { + message: string + exitReason: LocalLaunchExitReason +} + +export class GrokSession extends AgentSessionBase { + readonly startedBy: 'runner' | 'terminal' + readonly startingMode: 'local' | 'remote' + localLaunchFailure: LocalLaunchFailure | null = null + private nativeSessionExists: boolean + + constructor(opts: { + api: ApiClient + client: ApiSessionClient + path: string + logPath: string + sessionId: string | null + messageQueue: MessageQueue2 + onModeChange: (mode: 'local' | 'remote') => void + mode?: 'local' | 'remote' + startedBy: 'runner' | 'terminal' + startingMode: 'local' | 'remote' + permissionMode?: PermissionMode + model?: string | null + effort?: string | null + }) { + super({ + api: opts.api, + client: opts.client, + path: opts.path, + logPath: opts.logPath, + sessionId: opts.sessionId, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.mode, + sessionLabel: 'GrokSession', + sessionIdLabel: 'Grok', + applySessionIdToMetadata: (metadata, sessionId) => ({ + ...metadata, + grokSessionId: sessionId + }), + permissionMode: opts.permissionMode, + model: opts.model, + effort: opts.effort + }) + + this.startedBy = opts.startedBy + this.startingMode = opts.startingMode + this.nativeSessionExists = opts.sessionId !== null + } + + registerPendingNativeSession = (sessionId: string): void => { + this.onSessionFound(sessionId) + this.nativeSessionExists = false + } + + registerExistingNativeSession = (sessionId: string): void => { + this.onSessionFound(sessionId) + this.nativeSessionExists = true + } + + shouldResumeNativeSession = (): boolean => this.nativeSessionExists + + markNativeSessionStarted = (): void => { + this.nativeSessionExists = true + } + + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode + } + + setModel = (model: string | null): void => { + this.model = model + } + + setEffort = (effort: string | null): void => { + this.effort = effort + } + + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason } + } + + sendAgentMessage = (message: unknown): void => { + this.client.sendAgentMessage(message) + } + + sendSessionEvent = (event: Parameters[0]): void => { + this.client.sendSessionEvent(event) + } +} diff --git a/cli/src/grok/types.ts b/cli/src/grok/types.ts new file mode 100644 index 00000000..6f9122ef --- /dev/null +++ b/cli/src/grok/types.ts @@ -0,0 +1,9 @@ +import type { GrokPermissionMode } from '@hapi/protocol/types' + +export type PermissionMode = GrokPermissionMode + +export interface GrokMode { + permissionMode: PermissionMode + model?: string | null + effort?: string | null +} diff --git a/cli/src/grok/utils/grokBackend.test.ts b/cli/src/grok/utils/grokBackend.test.ts new file mode 100644 index 00000000..68a911b7 --- /dev/null +++ b/cli/src/grok/utils/grokBackend.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + buildGrokAgentArgs, + formatGrokError, + isGrokBuildAuxiliaryQuotaError +} from './grokBackend' + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function setWindowsPlatform(): void { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) +} + +describe('buildGrokAgentArgs', () => { + it('starts the official Grok ACP stdio agent', () => { + expect(buildGrokAgentArgs({ cwd: '/tmp/project' })).toEqual([ + '--cwd', '/tmp/project', 'agent', 'stdio' + ]) + }) + + it('places agent options before the stdio subcommand', () => { + expect(buildGrokAgentArgs({ + cwd: '/tmp/project', + model: 'grok-4.5', + effort: 'low' + })).toEqual([ + '--cwd', '/tmp/project', + 'agent', + '--model', 'grok-4.5', + '--reasoning-effort', 'low', + 'stdio' + ]) + }) + + it('rejects dynamic shell metacharacters before a Windows ACP spawn', () => { + setWindowsPlatform() + + expect(() => buildGrokAgentArgs({ cwd: 'C:\\repo&whoami' })).toThrow('Invalid cwd') + expect(() => buildGrokAgentArgs({ + cwd: 'C:\\repo', + model: 'grok|whoami' + })).toThrow('Invalid model') + expect(() => buildGrokAgentArgs({ + cwd: 'C:\\repo', + effort: 'low%PATH%' + })).toThrow('Invalid effort') + }) +}) + +describe('formatGrokError', () => { + it('turns ACP auth failures into an actionable login hint', () => { + expect(formatGrokError(new Error('Authentication required: no auth method id provided'))) + .toContain('grok login --device-auth') + }) + + it('preserves unrelated Grok errors', () => { + expect(formatGrokError(new Error('Payment Required'))).toBe('Payment Required') + }) + + it('strips terminal color codes from surfaced errors', () => { + expect(formatGrokError('\u001b[31mERROR\u001b[0m Payment Required')) + .toBe('ERROR Payment Required') + }) +}) + +describe('isGrokBuildAuxiliaryQuotaError', () => { + const auxiliary402 = [ + '\u001b[31mERROR\u001b[0m responses API error status=402 Payment Required', + 'personal-team-blocked:spending-limit: You have run out of credits or need a Grok subscription.', + 'model_id=grok-build' + ].join(' ') + + it('recognizes the non-fatal grok-build side request when another model is active', () => { + expect(isGrokBuildAuxiliaryQuotaError(auxiliary402, 'grok-4.5')).toBe(true) + }) + + it('does not hide a quota error when grok-build is the active model', () => { + expect(isGrokBuildAuxiliaryQuotaError(auxiliary402, 'grok-build')).toBe(false) + }) + + it('does not hide unrelated 402 errors', () => { + expect(isGrokBuildAuxiliaryQuotaError( + 'status=402 Payment Required model_id=grok-4.5 spending-limit', + 'grok-4.5' + )).toBe(false) + }) +}) diff --git a/cli/src/grok/utils/grokBackend.ts b/cli/src/grok/utils/grokBackend.ts new file mode 100644 index 00000000..66d84783 --- /dev/null +++ b/cli/src/grok/utils/grokBackend.ts @@ -0,0 +1,76 @@ +import { AcpSdkBackend } from '@/agent/backends/acp' +import { assertSafeWindowsShellArg } from './windowsShellArgs' + +const ANSI_SGR_PATTERN = /\u001b\[[0-9;]*m/g + +function stripAnsi(value: string): string { + return value.replace(ANSI_SGR_PATTERN, '') +} + +function filterEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value + } + } + return result +} + +export function buildGrokAgentArgs(opts: { cwd: string; model?: string; effort?: string }): string[] { + assertSafeWindowsShellArg(opts.cwd, 'cwd') + if (opts.model) assertSafeWindowsShellArg(opts.model, 'model') + if (opts.effort) assertSafeWindowsShellArg(opts.effort, 'effort') + + // --cwd is a top-level Grok flag and must precede the `agent` subcommand. + // session/new also carries cwd, but setting it at process start ensures + // Grok discovers the correct project rules/plugins before initialization. + const args = ['--cwd', opts.cwd, 'agent'] + if (opts.model) { + args.push('--model', opts.model) + } + if (opts.effort) { + args.push('--reasoning-effort', opts.effort) + } + args.push('stdio') + return args +} + +export function createGrokBackend(opts: { + cwd: string + model?: string + effort?: string +}): AcpSdkBackend { + return new AcpSdkBackend({ + command: 'grok', + args: buildGrokAgentArgs(opts), + env: filterEnv(process.env) + }) +} + +export function formatGrokError(error: unknown): string { + const message = stripAnsi(error instanceof Error ? error.message : String(error)) + if (/authentication required|no auth method id provided/i.test(message)) { + return 'Grok authentication required. Run `grok login --device-auth` on this machine, or configure XAI_API_KEY.' + } + return message +} + +/** + * Grok 0.2.93 generates a session title through its separate + * GROK_SESSION_SUMMARY_MODEL route. Accounts whose catalog only exposes + * grok-4.5 can receive a non-fatal 402 for that default grok-build request + * while the selected grok-4.5 turn continues normally. The prompt rejection, + * if any, is reported separately by backend.prompt(), so suppressing this + * auxiliary stderr line cannot hide a failed main turn. + */ +export function isGrokBuildAuxiliaryQuotaError( + value: string, + activeModel: string | null | undefined +): boolean { + if (!activeModel || activeModel === 'grok-build') return false + const message = stripAnsi(value) + return /(?:status\s*=\s*)?402\s+Payment Required/i.test(message) + && /model_id\s*=\s*grok-build\b/i.test(message) + && /(?:spending-limit|run out of credits|need a Grok subscription)/i.test(message) +} diff --git a/cli/src/grok/utils/permissionHandler.test.ts b/cli/src/grok/utils/permissionHandler.test.ts new file mode 100644 index 00000000..4e8ad36e --- /dev/null +++ b/cli/src/grok/utils/permissionHandler.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ApiSessionClient } from '@/api/apiSession' +import type { AgentState } from '@/api/types' +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types' +import { GrokPermissionHandler } from './permissionHandler' + +vi.mock('@/ui/logger', () => ({ logger: { debug: vi.fn() } })) + +function createHarness(mode: 'default' | 'auto' | 'plan' | 'bypassPermissions') { + let state: AgentState = { requests: {}, completedRequests: {} } + let backendHandler: ((request: PermissionRequest) => void) | null = null + const responses: PermissionResponse[] = [] + const rpcHandlers = new Map Promise | unknown>() + const session = { + rpcHandlerManager: { + registerHandler(method: string, handler: (payload: unknown) => Promise | unknown) { + rpcHandlers.set(method, handler) + } + }, + updateAgentState(handler: (current: AgentState) => AgentState) { + state = handler(state) + } + } as unknown as ApiSessionClient + const backend: AgentBackend = { + async initialize() {}, + async newSession() { return 'session-1' }, + async prompt() {}, + async cancelPrompt() {}, + async respondToPermission(_sessionId, _request, response) { responses.push(response) }, + onPermissionRequest(handler) { backendHandler = handler }, + async disconnect() {} + } + new GrokPermissionHandler(session, backend, () => mode) + return { + state: () => state, + responses, + rpcHandlers, + emit(request: PermissionRequest) { + if (!backendHandler) throw new Error('handler missing') + backendHandler(request) + } + } +} + +function request(): PermissionRequest { + return { + id: 'perm-1', + sessionId: 'session-1', + toolCallId: 'tool-1', + title: 'Shell', + rawInput: { command: 'pwd' }, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' }, + { optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' } + ] + } +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('GrokPermissionHandler', () => { + it('queues default-mode requests for HAPI approval', () => { + const harness = createHarness('default') + harness.emit(request()) + expect(harness.responses).toEqual([]) + expect(harness.state().requests?.['perm-1']).toMatchObject({ tool: 'Shell' }) + }) + + it('queues dangerous requests that Grok Auto leaves for user approval', () => { + const harness = createHarness('auto') + harness.emit(request()) + expect(harness.responses).toEqual([]) + expect(harness.state().requests?.['perm-1']).toMatchObject({ tool: 'Shell' }) + }) + + it('auto-approves bypassPermissions with the advertised allow-always option', async () => { + const harness = createHarness('bypassPermissions') + harness.emit(request()) + await flush() + expect(harness.responses).toEqual([{ outcome: 'selected', optionId: 'allow-always' }]) + expect(harness.state().completedRequests?.['perm-1']).toMatchObject({ + status: 'approved', + decision: 'approved_for_session' + }) + }) + + it('denies tool execution in plan mode', async () => { + const harness = createHarness('plan') + harness.emit(request()) + await flush() + expect(harness.responses).toEqual([{ outcome: 'selected', optionId: 'reject-once' }]) + expect(harness.state().completedRequests?.['perm-1']).toMatchObject({ + status: 'denied', + decision: 'denied' + }) + }) +}) diff --git a/cli/src/grok/utils/permissionHandler.ts b/cli/src/grok/utils/permissionHandler.ts new file mode 100644 index 00000000..e1b31bca --- /dev/null +++ b/cli/src/grok/utils/permissionHandler.ts @@ -0,0 +1,188 @@ +import type { ApiSessionClient } from '@/api/apiSession' +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types' +import type { GrokPermissionMode } from '@hapi/protocol/types' +import { deriveToolName } from '@/agent/utils' +import { logger } from '@/ui/logger' +import { + BasePermissionHandler, + type AutoApprovalDecision, + type PendingPermissionRequest, + type PermissionCompletion +} from '@/modules/common/permission/BasePermissionHandler' + +interface PermissionResponseMessage { + id: string + approved: boolean + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort' + reason?: string +} + +function deriveToolInput(request: PermissionRequest): unknown { + return request.rawInput !== undefined ? request.rawInput : request.rawOutput +} + +function pickOptionId( + request: PermissionRequest, + preferredKinds: string[], + options: { fallbackToFirst?: boolean } = {} +): string | null { + for (const kind of preferredKinds) { + const match = request.options.find((option) => option.kind === kind) + if (match) return match.optionId + } + if (options.fallbackToFirst === false) return null + return request.options[0]?.optionId ?? null +} + +function mapDecisionToOutcome( + request: PermissionRequest, + decision: PermissionResponseMessage['decision'] +): PermissionResponse { + if (decision === 'abort') return { outcome: 'cancelled' } + if (decision === 'approved_for_session') { + const optionId = pickOptionId(request, ['allow_always', 'allow_once']) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } + } + if (decision === 'approved') { + const optionId = pickOptionId(request, ['allow_once', 'allow_always']) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } + } + const optionId = pickOptionId(request, ['reject_once', 'reject_always']) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } +} + +function planDenial(request: PermissionRequest): PermissionResponse { + const optionId = pickOptionId(request, ['reject_once', 'reject_always'], { fallbackToFirst: false }) + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' } +} + +export class GrokPermissionHandler extends BasePermissionHandler { + private readonly pendingBackendRequests = new Map() + + constructor( + session: ApiSessionClient, + private readonly backend: AgentBackend, + private readonly getPermissionMode: () => GrokPermissionMode | undefined + ) { + super(session) + this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)) + } + + private handlePermissionRequest(request: PermissionRequest): void { + const toolName = deriveToolName({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }) + const toolInput = deriveToolInput(request) + const mode = this.getPermissionMode() ?? 'default' + + const autoDecision: AutoApprovalDecision | null = mode === 'bypassPermissions' + ? 'approved_for_session' + : this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId) + if (autoDecision) { + void this.autoApprove(request, toolName, toolInput, autoDecision) + return + } + if (mode === 'plan') { + void this.denyForPlanMode(request, toolName, toolInput) + return + } + + this.pendingBackendRequests.set(request.id, request) + this.addPendingRequest(request.id, toolName, toolInput, { + resolve: () => {}, + reject: () => {} + }) + logger.debug(`[Grok] Permission request queued for ${toolName} (${request.id})`) + } + + private async autoApprove( + request: PermissionRequest, + toolName: string, + toolInput: unknown, + decision: AutoApprovalDecision + ): Promise { + await this.backend.respondToPermission(request.sessionId, request, mapDecisionToOutcome(request, decision)) + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: Date.now(), + completedAt: Date.now(), + status: 'approved', + decision + } + } + })) + } + + private async denyForPlanMode( + request: PermissionRequest, + toolName: string, + toolInput: unknown + ): Promise { + const outcome = planDenial(request) + await this.backend.respondToPermission(request.sessionId, request, outcome) + const status = outcome.outcome === 'selected' ? 'denied' : 'canceled' + const timestamp = Date.now() + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: timestamp, + completedAt: timestamp, + status, + reason: 'Plan mode blocks tool execution', + decision: status === 'denied' ? 'denied' : 'abort' + } + } + })) + } + + protected async handlePermissionResponse( + response: PermissionResponseMessage, + pending: PendingPermissionRequest + ): Promise { + const request = this.pendingBackendRequests.get(response.id) + this.pendingBackendRequests.delete(response.id) + const decision = response.decision ?? (response.approved ? 'approved' : 'denied') + + if (decision === 'abort' && request) { + await this.backend.cancelPrompt(request.sessionId) + } + if (request) { + await this.backend.respondToPermission(request.sessionId, request, mapDecisionToOutcome(request, decision)) + } + pending.resolve() + + return { + status: response.approved ? 'approved' : 'denied', + decision, + reason: response.reason + } + } + + protected handleMissingPendingResponse(response: PermissionResponseMessage): void { + logger.debug('[Grok] Permission response received for unknown request', response.id) + } + + async cancelAll(reason: string): Promise { + const pending = Array.from(this.pendingBackendRequests.values()) + this.pendingBackendRequests.clear() + for (const request of pending) { + await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }) + } + this.cancelPendingRequests({ + completedReason: reason, + rejectMessage: reason, + decision: 'abort' + }) + } +} diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts new file mode 100644 index 00000000..c4394141 --- /dev/null +++ b/cli/src/grok/utils/systemPrompt.ts @@ -0,0 +1,2 @@ +export const GROK_TITLE_INSTRUCTION = + 'Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.' diff --git a/cli/src/grok/utils/windowsShellArgs.ts b/cli/src/grok/utils/windowsShellArgs.ts new file mode 100644 index 00000000..b999870d --- /dev/null +++ b/cli/src/grok/utils/windowsShellArgs.ts @@ -0,0 +1,8 @@ +const WINDOWS_SHELL_ARG_PATTERN = /[&|<>^()%!"\r\n]/u + +/** Reject dynamic values before passing them to a Windows `shell: true` spawn. */ +export function assertSafeWindowsShellArg(value: string, label: string): void { + if (process.platform === 'win32' && WINDOWS_SHELL_ARG_PATTERN.test(value)) { + throw new Error(`Invalid ${label}: contains Windows shell metacharacters`) + } +} diff --git a/cli/src/modules/common/grokModels.test.ts b/cli/src/modules/common/grokModels.test.ts new file mode 100644 index 00000000..9df7b980 --- /dev/null +++ b/cli/src/modules/common/grokModels.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from 'vitest' +import { buildGrokModelsArgs, parseGrokInitializeModels, parseGrokModelsOutput } from './grokModels' + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +describe('Grok model discovery', () => { + test('runs the official model listing command in the selected cwd', () => { + expect(buildGrokModelsArgs('/home/user/project')).toEqual([ + '--cwd', '/home/user/project', 'models' + ]) + }) + + test('rejects shell metacharacters in a Windows discovery cwd', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + + expect(() => buildGrokModelsArgs('C:\\repo&whoami')).toThrow('Invalid cwd') + }) + + test('parses available and default models from Grok Build output', () => { + expect(parseGrokModelsOutput(` +You are logged in with grok.com. + +Default model: grok-4.5 + +Available models: + * grok-4.5 (default) + * custom-fast +`)).toEqual({ + availableModels: [ + { modelId: 'grok-4.5' }, + { modelId: 'custom-fast' } + ], + currentModelId: 'grok-4.5' + }) + }) + + test('parses model names and per-model efforts from ACP initialize metadata', () => { + expect(parseGrokInitializeModels({ + _meta: { + availableCommands: [{ name: 'auto' }], + modelState: { + currentModelId: 'grok-4.5', + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + _meta: { + reasoningEfforts: [ + { value: 'high', label: 'High Effort', default: true }, + { value: 'low', label: 'Low Effort', default: false } + ] + } + }] + } + } + })).toEqual({ + currentModelId: 'grok-4.5', + autoPermissionModeSupported: true, + availableModels: [{ + modelId: 'grok-4.5', + name: 'Grok 4.5', + reasoningEfforts: [ + { value: 'high', name: 'High Effort', isDefault: true }, + { value: 'low', name: 'Low Effort', isDefault: false } + ] + }] + }) + }) +}) diff --git a/cli/src/modules/common/grokModels.ts b/cli/src/modules/common/grokModels.ts new file mode 100644 index 00000000..2a4d5747 --- /dev/null +++ b/cli/src/modules/common/grokModels.ts @@ -0,0 +1,241 @@ +import { spawn } from 'node:child_process' +import { asString, isObject } from '@hapi/protocol' +import type { GrokModelSummary, GrokModelsResponse, GrokReasoningEffortOption } from '@hapi/protocol/apiTypes' +import { AcpStdioTransport } from '@/agent/backends/acp/AcpStdioTransport' +import { assertSafeWindowsShellArg } from '@/grok/utils/windowsShellArgs' +import { getErrorMessage } from './rpcResponses' +import packageJson from '../../../package.json' + +export interface ListGrokModelsForCwdRequest { + cwd?: string +} + +export type ListGrokModelsForCwdResponse = GrokModelsResponse + +interface CacheEntry { + expiresAt: number + response: ListGrokModelsForCwdResponse +} + +const CACHE_TTL_MS = 60_000 +const PROBE_TIMEOUT_MS = 15_000 +const SETTINGS_PROBE_GRACE_MS = 300 +const cache = new Map() +const inflight = new Map>() + +export function buildGrokModelsArgs(cwd: string): string[] { + assertSafeWindowsShellArg(cwd, 'cwd') + return ['--cwd', cwd, 'models'] +} + +export function parseGrokModelsOutput(output: string): { + availableModels: GrokModelSummary[] + currentModelId: string | null +} { + const availableModels: GrokModelSummary[] = [] + const seen = new Set() + let currentModelId: string | null = null + let inAvailableModels = false + + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim() + if (line.startsWith('Default model:')) { + currentModelId = line.slice('Default model:'.length).trim() || null + continue + } + if (line === 'Available models:') { + inAvailableModels = true + continue + } + if (!inAvailableModels || !line.startsWith('*')) continue + + const modelId = line.slice(1).replace(/\s+\(default\)\s*$/, '').trim() + if (!modelId || seen.has(modelId)) continue + seen.add(modelId) + availableModels.push({ modelId }) + } + + if (currentModelId && !seen.has(currentModelId)) { + availableModels.unshift({ modelId: currentModelId }) + } + + return { availableModels, currentModelId } +} + +export function parseGrokInitializeModels(response: unknown): { + availableModels: GrokModelSummary[] + currentModelId: string | null + autoPermissionModeSupported: boolean +} { + if (!isObject(response) || !isObject(response._meta) || !isObject(response._meta.modelState)) { + return { availableModels: [], currentModelId: null, autoPermissionModeSupported: false } + } + const state = response._meta.modelState + const rawCommands = Array.isArray(response._meta.availableCommands) + ? response._meta.availableCommands + : [] + const autoPermissionModeSupported = rawCommands.some( + (entry) => isObject(entry) && asString(entry.name) === 'auto' + ) + const currentModelId = asString(state.currentModelId) + const rawModels = Array.isArray(state.availableModels) ? state.availableModels : [] + const availableModels = rawModels + .filter((entry): entry is Record => isObject(entry)) + .map((entry): GrokModelSummary | null => { + const modelId = asString(entry.modelId) + if (!modelId) return null + const meta = isObject(entry._meta) ? entry._meta : null + const rawEfforts = meta && Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [] + const reasoningEfforts = rawEfforts + .filter((effort): effort is Record => isObject(effort)) + .map((effort): GrokReasoningEffortOption => ({ + value: asString(effort.value) ?? asString(effort.id) ?? '', + name: asString(effort.label) ?? undefined, + isDefault: effort.default === true + })) + .filter((effort) => effort.value.length > 0) + return { + modelId, + name: asString(entry.name) ?? undefined, + ...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}) + } + }) + .filter((entry): entry is GrokModelSummary => entry !== null) + + return { availableModels, currentModelId, autoPermissionModeSupported } +} + +async function runGrokModelsCliProbe(cwd: string): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('grok', buildGrokModelsArgs(cwd), { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: process.platform === 'win32' + }) + let stdout = '' + let stderr = '' + let settled = false + + const timeout = setTimeout(() => { + if (settled) return + settled = true + child.kill('SIGTERM') + reject(new Error('Grok model discovery timed out')) + }, PROBE_TIMEOUT_MS) + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', (error) => { + if (settled) return + settled = true + clearTimeout(timeout) + reject(error) + }) + child.on('exit', (code) => { + if (settled) return + settled = true + clearTimeout(timeout) + if (code !== 0) { + reject(new Error(stderr.trim() || `grok models exited with code ${code}`)) + return + } + resolve({ success: true, ...parseGrokModelsOutput(stdout) }) + }) + }) +} + +async function runGrokModelsProbe(cwd: string): Promise { + // The primary ACP probe also uses shell mode on Windows through AcpStdioTransport. + assertSafeWindowsShellArg(cwd, 'cwd') + const transport = new AcpStdioTransport({ + command: 'grok', + args: ['--cwd', cwd, 'agent', '--reasoning-effort', 'low', 'stdio'], + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) + }) + try { + let resolveAutoPermissionMode: ((supported: boolean) => void) | null = null + const autoPermissionMode = new Promise((resolve) => { + resolveAutoPermissionMode = resolve + }) + transport.onNotification((method, params) => { + if ( + method === '_x.ai/settings/update' + && isObject(params) + && 'auto_permission_mode_enabled' in params + ) { + resolveAutoPermissionMode?.(params.auto_permission_mode_enabled === true) + resolveAutoPermissionMode = null + } + }) + const response = await transport.sendRequest('initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { parameterizedModelPicker: true } + }, + clientInfo: { name: 'hapi-grok-models', version: packageJson.version } + }, { timeoutMs: PROBE_TIMEOUT_MS }) + const parsed = parseGrokInitializeModels(response) + if (parsed.availableModels.length > 0) { + const remoteSupport = await Promise.race([ + autoPermissionMode, + new Promise((resolve) => setTimeout(resolve, SETTINGS_PROBE_GRACE_MS, null)) + ]) + return { + success: true, + ...parsed, + autoPermissionModeSupported: parsed.autoPermissionModeSupported || remoteSupport === true + } + } + } catch { + // Older Grok builds may not expose modelState during initialize. + // Fall back to the stable `grok models` command below. + } finally { + await transport.close().catch(() => undefined) + } + return await runGrokModelsCliProbe(cwd) +} + +export async function listGrokModelsForCwd(cwd: string): Promise { + const trimmed = cwd?.trim() + if (!trimmed) return { success: false, error: 'cwd is required' } + + const cached = cache.get(trimmed) + if (cached && cached.expiresAt > Date.now()) return cached.response + + const running = inflight.get(trimmed) + if (running) return running + + const promise = (async () => { + try { + const response = await runGrokModelsProbe(trimmed) + if (response.success) { + cache.set(trimmed, { expiresAt: Date.now() + CACHE_TTL_MS, response }) + } + return response + } catch (error) { + return { + success: false, + error: getErrorMessage(error, 'Failed to discover Grok models') + } + } finally { + inflight.delete(trimmed) + } + })() + + inflight.set(trimmed, promise) + return promise +} + +export function _resetGrokModelsCacheForTests(): void { + cache.clear() + inflight.clear() +} diff --git a/cli/src/modules/common/handlers/grokModels.ts b/cli/src/modules/common/handlers/grokModels.ts new file mode 100644 index 00000000..191329a9 --- /dev/null +++ b/cli/src/modules/common/handlers/grokModels.ts @@ -0,0 +1,23 @@ +import { logger } from '@/ui/logger' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { + listGrokModelsForCwd, + type ListGrokModelsForCwdRequest, + type ListGrokModelsForCwdResponse +} from '../grokModels' +import { getErrorMessage, rpcError } from '../rpcResponses' + +export function registerGrokModelHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler( + RPC_METHODS.ListGrokModelsForCwd, + async (data) => { + try { + return await listGrokModelsForCwd(typeof data?.cwd === 'string' ? data.cwd : '') + } catch (error) { + logger.debug('Failed to list Grok models:', error) + return rpcError(getErrorMessage(error, 'Failed to list Grok models')) + } + } + ) +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index b555593a..4b484349 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -3,6 +3,7 @@ import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' import { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' +import { registerGrokModelHandlers } from './handlers/grokModels' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -17,6 +18,7 @@ export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, wor registerCodexModelHandlers(rpcHandlerManager) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) + registerGrokModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/cli/src/modules/common/skills.test.ts b/cli/src/modules/common/skills.test.ts index e646df6b..08813e2e 100644 --- a/cli/src/modules/common/skills.test.ts +++ b/cli/src/modules/common/skills.test.ts @@ -95,6 +95,18 @@ describe('listSkills', () => { expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents', 'skill-creator']) }) + it('lists Grok user and project skills alongside shared .agents skills', async () => { + const repoRoot = join(sandboxDir, 'grok-repo') + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(homeDir, '.grok', 'skills', 'grok-user'), 'grok-user', 'Grok user skill') + await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill') + await writeSkill(join(repoRoot, '.grok', 'skills', 'grok-project'), 'grok-project', 'Grok project skill') + + const skills = await listSkills(repoRoot, { flavor: 'grok' }) + + expect(skills.map((skill) => skill.name)).toEqual(['grok-project', 'grok-user', 'shared']) + }) + it('scopes user skills to the requested flavor', async () => { await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill') await writeSkill(join(homeDir, '.claude', 'skills', 'claude-only'), 'claude-only', 'Claude skill') diff --git a/cli/src/modules/common/skills.ts b/cli/src/modules/common/skills.ts index 47b169da..a9db9e44 100644 --- a/cli/src/modules/common/skills.ts +++ b/cli/src/modules/common/skills.ts @@ -43,6 +43,8 @@ function getAgentConfigDir(flavor?: string): string { return process.env.CLAUDE_CONFIG_DIR || join(getHomeDirectory(), '.claude'); case 'codex': return process.env.CODEX_HOME || join(getHomeDirectory(), '.codex'); + case 'grok': + return process.env.GROK_HOME || join(getHomeDirectory(), '.grok'); default: return join(getHomeDirectory(), `.${normalizedFlavor}`); } @@ -58,6 +60,9 @@ function getUserSkillsRoots(flavor?: string): string[] { case 'codex': roots.push(join(getAgentConfigDir(flavor), 'skills')); break; + case 'grok': + roots.push(join(getAgentConfigDir(flavor), 'skills')); + break; } return roots; } @@ -75,6 +80,9 @@ function getProjectSkillsRoots(directory: string, flavor?: string): string[] { case 'codex': roots.push(join(directory, '.codex', 'skills')); break; + case 'grok': + roots.push(join(directory, '.grok', 'skills')); + break; } return roots; } diff --git a/cli/src/modules/common/slashCommands.test.ts b/cli/src/modules/common/slashCommands.test.ts index 5a11acef..2b4171c5 100644 --- a/cli/src/modules/common/slashCommands.test.ts +++ b/cli/src/modules/common/slashCommands.test.ts @@ -220,6 +220,25 @@ describe('listSlashCommands', () => { }) }) + it('exposes only Grok ACP-native built-ins', async () => { + const commands = await listSlashCommands('grok', projectDir) + const builtinNames = commands + .filter((command) => command.source === 'builtin') + .map((command) => command.name) + + expect(builtinNames).toEqual([ + 'compact', + 'context', + 'session-info', + 'goal', + 'always-approve', + 'auto', + ]) + for (const composerControl of ['model', 'effort', 'plan', 'view-plan']) { + expect(builtinNames).not.toContain(composerControl) + } + }) + it('lets project opencode prompts override same-name built-ins', async () => { await writeFile( join(projectDir, '.opencode', 'command', 'status.md'), diff --git a/cli/src/modules/common/slashCommands.ts b/cli/src/modules/common/slashCommands.ts index 32217cb7..1d264bbb 100644 --- a/cli/src/modules/common/slashCommands.ts +++ b/cli/src/modules/common/slashCommands.ts @@ -69,6 +69,8 @@ function getUserCommandsDir(agent: string): string | null { const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'); return join(xdgConfigHome, 'opencode', 'command'); } + case 'grok': + return join(homedir(), '.agents', 'commands'); default: // Gemini and other agents don't have user commands return null; @@ -87,6 +89,8 @@ function getProjectCommandsDir(agent: string, projectDir: string): string | null return join(projectDir, '.codex', 'prompts'); case 'opencode': return join(projectDir, '.opencode', 'command'); + case 'grok': + return join(projectDir, '.grok', 'commands'); default: // Gemini and other agents don't have project commands return null; diff --git a/cli/src/runner/README.md b/cli/src/runner/README.md index ba782dab..e5ddfd33 100644 --- a/cli/src/runner/README.md +++ b/cli/src/runner/README.md @@ -85,6 +85,7 @@ The runner supports spawning sessions with different AI agents: | `claude` (default) | `hapi claude` | `CLAUDE_CODE_OAUTH_TOKEN` | | `codex` | `hapi codex` | `CODEX_HOME` (temp directory with `auth.json`) | | `gemini` | `hapi gemini` | - | +| `grok` | `hapi grok` | Grok CLI login or `XAI_API_KEY` | | `opencode` | `hapi opencode` | OpenCode config (no token injection) | ### Token Authentication @@ -92,6 +93,7 @@ The runner supports spawning sessions with different AI agents: When spawning a session with a token: - **Claude**: Sets `CLAUDE_CODE_OAUTH_TOKEN` environment variable - **Codex**: Creates temp directory at `os.tmpdir()/hapi-codex-*`, writes token to `auth.json`, sets `CODEX_HOME` +- **Grok Build**: No token injection; relies on Grok CLI login or `XAI_API_KEY` in the runner environment - **OpenCode**: No token injection; relies on OpenCode's own configuration ## 3. Session Management diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 7d90e489..afc208cc 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -170,4 +170,24 @@ describe('buildCliArgs', () => { expect(args).toContain('--effort') expect(args).toContain('high') }) + + it('builds Grok runner resume, model, effort, and permission arguments', () => { + const args = buildCliArgs('grok', { + directory: '/tmp', + resumeSessionId: 'grok-session-1', + model: 'grok-4.5', + effort: 'low', + permissionMode: 'plan' + }) + + expect(args).toEqual([ + 'grok', + '--resume', 'grok-session-1', + '--hapi-starting-mode', 'remote', + '--started-by', 'runner', + '--model', 'grok-4.5', + '--effort', 'low', + '--permission-mode', 'plan' + ]) + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c696dd44..15cea991 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1090,13 +1090,15 @@ export function buildCliArgs( ? 'codex' : agent === 'cursor' ? 'cursor' - : agent === 'kimi' - ? 'kimi' - : agent === 'opencode' - ? 'opencode' - : agent === 'pi' - ? 'pi' - : 'claude'; + : agent === 'grok' + ? 'grok' + : agent === 'kimi' + ? 'kimi' + : agent === 'opencode' + ? 'opencode' + : agent === 'pi' + ? 'pi' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { @@ -1114,7 +1116,7 @@ export function buildCliArgs( if (options.model) { args.push('--model', options.model); } - if (options.effort && (agent === 'claude' || agent === 'pi')) { + if (options.effort && (agent === 'claude' || agent === 'grok' || agent === 'pi')) { args.push('--effort', options.effort); } if (options.modelReasoningEffort && (agent === 'codex' || agent === 'opencode')) { diff --git a/cli/src/ui/ink/GrokDisplay.tsx b/cli/src/ui/ink/GrokDisplay.tsx new file mode 100644 index 00000000..eba83455 --- /dev/null +++ b/cli/src/ui/ink/GrokDisplay.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { MessageBuffer, type BufferedMessage } from './messageBuffer'; +import { useSwitchControls } from './useSwitchControls'; + +interface GrokDisplayProps { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void; + onSwitchToLocal?: () => void; +} + +function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string | null { + const prefix = `[${tag}:`; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.type !== 'system') { + continue; + } + if (!message.content.startsWith(prefix)) { + continue; + } + const match = message.content.match(/\[\w+:(.+?)\]/); + if (match && match[1]) { + return match[1]; + } + } + return null; +} + +export const GrokDisplay: React.FC = ({ + messageBuffer, + logPath, + onExit, + onSwitchToLocal +}) => { + const [messages, setMessages] = useState([]); + const [model, setModel] = useState(null); + const [permissionMode, setPermissionMode] = useState(null); + const { confirmationMode, actionInProgress } = useSwitchControls({ + onExit, + onSwitch: onSwitchToLocal + }); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + const nextModel = extractTag(newMessages, 'MODEL'); + if (nextModel) { + setModel(nextModel); + } + const nextMode = extractTag(newMessages, 'MODE'); + if (nextMode) { + setPermissionMode(nextMode); + } + }); + + return () => { + unsubscribe(); + }; + }, [messageBuffer]); + + const getMessageColor = (type: BufferedMessage['type']): string => { + switch (type) { + case 'user': return 'magenta'; + case 'assistant': return 'cyan'; + case 'system': return 'blue'; + case 'tool': return 'yellow'; + case 'result': return 'green'; + case 'status': return 'gray'; + default: return 'white'; + } + }; + + const formatMessage = (msg: BufferedMessage): string => { + const lines = msg.content.split('\n'); + const maxLineLength = Math.max(1, terminalWidth - 10); + return lines.map(line => { + if (line.length <= maxLineLength) return line; + const chunks: string[] = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join('\n'); + }).join('\n'); + }; + + const visibleMessages = messages.filter((msg) => { + if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { + return false; + } + if (msg.type === 'system' && msg.content.startsWith('[MODE:')) { + return false; + } + return true; + }); + + return ( + + + + Grok Build Agent Messages + {'-'.repeat(Math.min(terminalWidth - 4, 60))} + + + + {visibleMessages.length === 0 ? ( + Waiting for messages... + ) : ( + visibleMessages + .slice(-Math.max(1, terminalHeight - 10)) + .map((msg) => ( + + + {formatMessage(msg)} + + + )) + )} + + + + + + {actionInProgress === 'exiting' ? ( + + Exiting agent... + + ) : actionInProgress === 'switching' ? ( + + Switching to local mode... + + ) : confirmationMode === 'exit' ? ( + + Press Ctrl-C again to exit the agent + + ) : confirmationMode === 'switch' ? ( + + Press space again to switch to local mode + + ) : ( + + Grok Build running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} + + )} + {(model || permissionMode) && ( + + {model ? `Model: ${model}` : 'Model: default'} + {permissionMode ? ` | Permission: ${permissionMode}` : ''} + + )} + {process.env.DEBUG && logPath && ( + + Debug logs: {logPath} + + )} + + + + ); +}; diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 655a6b70..676e04af 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -23,6 +23,7 @@ export default defineConfig({ { text: 'PWA', link: '/guide/pwa' }, { text: 'How it Works', link: '/guide/how-it-works' }, { text: 'Cursor Agent', link: '/guide/cursor' }, + { text: 'Grok Build', link: '/guide/grok' }, { text: 'Voice Assistant', link: '/guide/voice-assistant' }, { text: 'Why HAPI', link: '/guide/why-hapi' }, { text: 'FAQ', link: '/guide/faq' } diff --git a/docs/guide/grok.md b/docs/guide/grok.md new file mode 100644 index 00000000..f56f6317 --- /dev/null +++ b/docs/guide/grok.md @@ -0,0 +1,90 @@ +# Grok Build + +HAPI can run the official Grok Build CLI locally and control the same coding session remotely from the Web/PWA. + +## Install + +Install Grok Build using the official installer: + +::: code-group + +```bash [macOS / Linux / WSL] +curl -fsSL https://x.ai/cli/install.sh | bash +``` + +```powershell [Windows PowerShell] +irm https://x.ai/cli/install.ps1 | iex +``` + +::: + +Verify the installation: + +```bash +grok version +``` + +## Authenticate + +HAPI reuses the Grok CLI's local authentication. On a headless runner machine, authenticate once with device-code login: + +```bash +grok login --device-auth +``` + +Alternatively, configure an xAI API key in the runner environment: + +```bash +export XAI_API_KEY="xai-..." +``` + +Do not place API keys in HAPI configuration files, logs, or a repository. + +## Start a session + +Start the native Grok Build TUI: + +```bash +hapi grok +``` + +Start with explicit launch settings: + +```bash +hapi grok --model grok-4.5 --effort low --permission-mode default +``` + +Sessions created from a HAPI runner start in remote mode automatically. Terminal-created sessions start in the native Grok TUI and can switch to remote control without parsing terminal output. + +## Permission modes + +HAPI exposes a conservative subset for the first integration: + +- `default` — tool requests are shown in HAPI for approval or denial. +- `plan` — HAPI asks Grok to plan only and rejects tool execution requests. +- `bypassPermissions` — tool requests are automatically approved for the session. + +Use `bypassPermissions` only in a trusted workspace. + +## Resume and handoff + +Remote mode uses Grok's ACP stdio agent (`grok agent stdio`). HAPI stores the native Grok session ID and uses it for: + +- ACP `session/load` after a restart. +- `grok --resume ` when switching back to the native TUI. +- `hapi resume ` from a terminal. + +For a new local session, HAPI supplies a UUID with `grok --session-id`, so the session can be resumed without scraping the fullscreen TUI. + +## Model and effort controls + +The Create page discovers Grok's ACP model catalog and the reasoning-effort choices advertised for each model. Remote sessions can switch both model and effort between turns; HAPI applies them through ACP `session/set_model` and `session/set_mode`. + +HAPI also exposes Grok's common slash commands, discovers skills from `.grok/skills`, `~/.grok/skills`, and shared `.agents/skills`, and asks Grok to set a concise HAPI session title after the first normal prompt. + +## Current limitations + +- OAuth/device-code login must be completed outside the HAPI Web UI. +- Grok subscription, credit, and model availability are controlled by xAI. + +If a remote session reports authentication failure, run `grok login --device-auth` on the runner machine and retry. diff --git a/hub/src/store/sessions.test.ts b/hub/src/store/sessions.test.ts index 90a89f87..ae5a92dc 100644 --- a/hub/src/store/sessions.test.ts +++ b/hub/src/store/sessions.test.ts @@ -152,6 +152,7 @@ describe('updateSessionMetadata: protocol resume token preservation', () => { ['codexSessionId', 'codex-thread-x'], ['geminiSessionId', 'gemini-thread-x'], ['opencodeSessionId', 'opencode-thread-x'], + ['grokSessionId', 'grok-thread-x'], ['cursorSessionId', 'cursor-thread-x'], ['kimiSessionId', 'kimi-thread-x'] ])('preserves %s across an archive metadata replacement', (field, value) => { diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index a5f80dcd..70543354 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -58,6 +58,7 @@ const SIMPLE_RESUME_TOKENS = [ 'codexSessionId', 'geminiSessionId', 'opencodeSessionId', + 'grokSessionId', 'cursorSessionId', 'kimiSessionId' ] as const diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 5a63f454..482167cb 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -10,6 +10,8 @@ import type { DirectoryEntry, FileReadResponse, GeneratedImageResponse, + GrokModelsResponse, + GrokReasoningEffortResponse, ListDirectoryResponse, OpencodeModelsResponse, OpencodeModelSummary, @@ -59,6 +61,8 @@ export type RpcCursorModel = CursorModelSummary export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse +export type RpcListGrokModelsResponse = GrokModelsResponse +export type RpcListGrokReasoningEffortOptionsResponse = GrokReasoningEffortResponse export type RpcListOpencodeReasoningEffortOptionsResponse = OpencodeReasoningEffortResponse export class RpcGateway { @@ -282,6 +286,18 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListOpencodeModelsForCwd, { cwd }) as RpcListOpencodeModelsResponse } + async listGrokModelsForCwd(machineId: string, cwd: string): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListGrokModelsForCwd, { cwd }) as RpcListGrokModelsResponse + } + + async listGrokModelsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.ListGrokModels, {}) as RpcListGrokModelsResponse + } + + async listGrokReasoningEffortOptionsForSession(sessionId: string): Promise { + return await this.sessionRpc(sessionId, RPC_METHODS.ListGrokReasoningEffortOptions, {}) as RpcListGrokReasoningEffortOptionsResponse + } + /** Generic Pi RPC call — routes all Pi-specific session RPCs through * a single entry point instead of per-method wrappers. */ async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index bdfafecd..7cec37f7 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1092,11 +1092,12 @@ export class SessionCache { private extractAgentSessionId( metadata: NonNullable - ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { + ): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'grokSessionId' | 'cursorSessionId' | 'piSessionId'; value: string } | null { if (metadata.codexSessionId) return { field: 'codexSessionId', value: metadata.codexSessionId } if (metadata.claudeSessionId) return { field: 'claudeSessionId', value: metadata.claudeSessionId } if (metadata.geminiSessionId) return { field: 'geminiSessionId', value: metadata.geminiSessionId } if (metadata.opencodeSessionId) return { field: 'opencodeSessionId', value: metadata.opencodeSessionId } + if (metadata.grokSessionId) return { field: 'grokSessionId', value: metadata.grokSessionId } if (metadata.cursorSessionId) return { field: 'cursorSessionId', value: metadata.cursorSessionId } if (metadata.piSessionId) return { field: 'piSessionId', value: metadata.piSessionId } return null diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 8fa18c07..7818b30f 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1450,6 +1450,47 @@ describe('session model', () => { } }) + it('resolves a local resume target for a Grok session', () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'local-resume-grok', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'grok', + grokSessionId: 'grok-session-1' + }, + { controlledByUser: false }, + 'default', + 'grok-4.5', + 'low' + ) + + const result = engine.resolveLocalResumeTarget(session.id, 'default') + + expect(result.type).toBe('success') + if (result.type === 'success') { + expect(result.target).toMatchObject({ + flavor: 'grok', + agentSessionId: 'grok-session-1', + model: 'grok-4.5', + effort: 'low' + }) + } + } finally { + engine.stop() + } + }) + it('recovers a Claude local resume target from stored messages', () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 414da247..1ca672db 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -32,6 +32,8 @@ import { type RpcListCodexModelsResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, + type RpcListGrokModelsResponse, + type RpcListGrokReasoningEffortOptionsResponse, type RpcListOpencodeReasoningEffortOptionsResponse, type RpcCursorModel, type RpcOpencodeModel, @@ -53,6 +55,8 @@ export type { RpcListCodexModelsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, + RpcListGrokModelsResponse, + RpcListGrokReasoningEffortOptionsResponse, RpcListOpencodeReasoningEffortOptionsResponse, RpcCursorModel, RpcOpencodeModel, @@ -763,6 +767,7 @@ export class SyncEngine { if (flavor === 'codex') return metadata.codexSessionId ?? null if (flavor === 'gemini') return metadata.geminiSessionId ?? null if (flavor === 'opencode') return metadata.opencodeSessionId ?? null + if (flavor === 'grok') return metadata.grokSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null if (flavor === 'kimi') return metadata.kimiSessionId ?? null if (flavor === 'pi') return metadata.piSessionId ?? null @@ -1469,6 +1474,7 @@ export class SyncEngine { && (prev?.claudeSessionId ?? null) === (next.claudeSessionId ?? null) && (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null) && (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null) + && (prev?.grokSessionId ?? null) === (next.grokSessionId ?? null) && (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null) && (prev?.piSessionId ?? null) === (next.piSessionId ?? null) && (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null) @@ -1615,6 +1621,18 @@ export class SyncEngine { return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd) } + async listGrokModelsForCwd(machineId: string, cwd: string): Promise { + return await this.rpcGateway.listGrokModelsForCwd(machineId, cwd) + } + + async listGrokModelsForSession(sessionId: string): Promise { + return await this.rpcGateway.listGrokModelsForSession(sessionId) + } + + async listGrokReasoningEffortOptionsForSession(sessionId: string): Promise { + return await this.rpcGateway.listGrokReasoningEffortOptionsForSession(sessionId) + } + /** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */ async callPiRpc(sessionId: string, method: string, params?: Record, timeoutMs?: number): Promise { return await this.rpcGateway.callPiRpc(sessionId, method, params, timeoutMs) diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 3c4e6457..6c0308cc 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -26,6 +26,51 @@ function createMachine(overrides?: Partial): Machine { } describe('machines routes', () => { + it('forwards Grok Auto permission mode when spawning', async () => { + const machine = createMachine() + let capturedPermissionMode: string | undefined + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + spawnSession: async ( + _machineId: string, + _directory: string, + _agent?: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedPermissionMode = permissionMode + return { type: 'success' as const, sessionId: 'session-1' } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/spawn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + directory: '/tmp/project', + agent: 'grok', + permissionMode: 'auto' + }) + }) + + expect(response.status).toBe(200) + expect(capturedPermissionMode).toBe('auto') + }) + it('returns Codex models for an online machine', async () => { const machine = createMachine() const engine = { @@ -121,6 +166,42 @@ describe('machines routes', () => { }) }) + it('forwards cwd to listGrokModelsForCwd for Create-session discovery', async () => { + const machine = createMachine() + const calls: Array<{ machineId: string; cwd: string }> = [] + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listGrokModelsForCwd: async (machineId: string, cwd: string) => { + calls.push({ machineId, cwd }) + return { + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + } + } + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request( + '/api/machines/machine-1/grok-models?cwd=' + encodeURIComponent('/home/user/proj') + ) + + expect(response.status).toBe(200) + expect(calls).toEqual([{ machineId: 'machine-1', cwd: '/home/user/proj' }]) + expect(await response.json()).toEqual({ + success: true, + availableModels: [{ modelId: 'grok-4.5' }], + currentModelId: 'grok-4.5' + }) + }) + it('returns 503 when cursor-models is requested without a sync engine', async () => { const app = new Hono() app.use('*', async (c, next) => { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 7288a42d..280695a1 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -50,7 +50,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.sessionType, parsed.data.worktreeName, undefined, - parsed.data.effort + parsed.data.effort, + parsed.data.permissionMode ) return c.json(result) }) @@ -163,6 +164,31 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/machines/:id/grok-models', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) return machine + + const cwd = (c.req.query('cwd') ?? '').trim() + if (!cwd) { + return c.json({ success: false, error: 'cwd query parameter is required' }, 400) + } + + try { + return c.json(await engine.listGrokModelsForCwd(machineId, cwd)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Grok models' + }, 500) + } + }) + app.get('/machines/:id/cursor-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 01f6d859..8db9c76e 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -98,6 +98,22 @@ function createApp(session: Session, opts?: { ], currentModelId: 'composer-2.5' }) + const listGrokModelsForSession = async () => ({ + success: true, + availableModels: [ + { + modelId: 'grok-4.5', + name: 'Grok 4.5', + reasoningEfforts: [{ value: 'low', name: 'Low' }] + } + ], + currentModelId: 'grok-4.5' + }) + const listGrokReasoningEffortOptionsForSession = async () => ({ + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low' + }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) const reopenSession = opts?.reopenSession ?? (async (sessionId: string) => ({ type: 'success' as const, @@ -115,6 +131,8 @@ function createApp(session: Session, opts?: { listCursorModelsForSession, listOpencodeModelsForSession, listOpencodeReasoningEffortOptionsForSession, + listGrokModelsForSession, + listGrokReasoningEffortOptionsForSession, resumeSession, reopenSession, archiveSession: archiveSessionMock, @@ -568,6 +586,41 @@ describe('sessions routes', () => { expect(applySessionConfigCalls).toEqual([]) }) + it('applies model changes for remote Grok sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'grok-4.5' }) + }) + + expect(response.status).toBe(200) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'grok-4.5' }] + ]) + }) + + it('rejects model changes for local Grok sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' }, + agentState: { controlledByUser: true, requests: {}, completedRequests: {} } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'grok-4.5' }) + }) + + expect(response.status).toBe(409) + expect(applySessionConfigCalls).toEqual([]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) @@ -607,6 +660,35 @@ describe('sessions routes', () => { ]) }) + it('applies effort changes for remote Grok sessions and rejects local control', async () => { + const remote = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' } + }) + const remoteApp = createApp(remote) + const remoteResponse = await remoteApp.app.request('/api/sessions/session-1/effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ effort: 'low' }) + }) + expect(remoteResponse.status).toBe(200) + expect(remoteApp.applySessionConfigCalls).toEqual([ + ['session-1', { effort: 'low' }] + ]) + + const local = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' }, + agentState: { controlledByUser: true, requests: {}, completedRequests: {} } + }) + const localApp = createApp(local) + const localResponse = await localApp.app.request('/api/sessions/session-1/effort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ effort: 'low' }) + }) + expect(localResponse.status).toBe(409) + expect(localApp.applySessionConfigCalls).toEqual([]) + }) + it('returns Codex models for active Codex sessions', async () => { const { app } = createApp(createSession()) @@ -640,6 +722,28 @@ describe('sessions routes', () => { }) }) + it('returns Grok model and effort catalogs for active Grok sessions', async () => { + const session = createSession({ + metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' } + }) + const { app } = createApp(session) + + const modelsResponse = await app.request('/api/sessions/session-1/grok-models') + expect(modelsResponse.status).toBe(200) + expect(await modelsResponse.json()).toMatchObject({ + success: true, + currentModelId: 'grok-4.5' + }) + + const effortResponse = await app.request('/api/sessions/session-1/grok-reasoning-effort-options') + expect(effortResponse.status).toBe(200) + expect(await effortResponse.json()).toEqual({ + success: true, + options: [{ value: 'low', name: 'Low' }], + currentValue: 'low' + }) + }) + it('rejects opencode-reasoning-effort-options for non-OpenCode sessions', async () => { const { app } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index f199a293..c5f64f81 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -490,6 +490,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (flavor === 'cursor') { return c.json({ error: 'Model selection can only be changed for remote Cursor sessions' }, 409) } + if (flavor === 'grok') { + return c.json({ error: 'Model selection can only be changed for remote Grok sessions' }, 409) + } } try { @@ -558,6 +561,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (!supportsEffort(flavor)) { return c.json({ error: 'Effort selection is not supported for this session type' }, 400) } + if (flavor === 'grok' && sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Effort can only be changed for remote Grok sessions' }, 409) + } try { await engine.applySessionConfig(sessionResult.sessionId, { effort: parsed.data.effort }) @@ -823,6 +829,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.get('/sessions/:id/grok-models', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + if (sessionResult.session.metadata?.flavor !== 'grok') { + return c.json({ success: false, error: 'Grok models are only available for Grok sessions' }, 400) + } + try { + return c.json(await engine.listGrokModelsForSession(sessionResult.sessionId)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Grok models' + }, 500) + } + }) + + app.get('/sessions/:id/grok-reasoning-effort-options', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) return engine + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) return sessionResult + if (sessionResult.session.metadata?.flavor !== 'grok') { + return c.json({ success: false, error: 'Grok effort options are only available for Grok sessions' }, 400) + } + try { + return c.json(await engine.listGrokReasoningEffortOptionsForSession(sessionResult.sessionId)) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Grok effort options' + }, 500) + } + }) + app.get('/sessions/:id/cursor-models', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 0167c12b..bb2997cf 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -251,6 +251,7 @@ export const SpawnSessionRequestSchema = z.object({ effort: z.string().optional(), modelReasoningEffort: z.string().optional(), yolo: z.boolean().optional(), + permissionMode: PermissionModeSchema.optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional() }) @@ -376,6 +377,34 @@ export type OpencodeModelsResponse = { export type ListOpencodeModelsResponse = OpencodeModelsResponse +export type GrokModelSummary = { + modelId: string + name?: string + reasoningEfforts?: GrokReasoningEffortOption[] +} + +export type GrokReasoningEffortOption = { + value: string + name?: string + isDefault?: boolean +} + +export type GrokModelsResponse = { + success: boolean + availableModels?: GrokModelSummary[] + currentModelId?: string | null + autoPermissionModeSupported?: boolean + error?: string +} +export type ListGrokModelsResponse = GrokModelsResponse + +export type GrokReasoningEffortResponse = { + success: boolean + options?: GrokReasoningEffortOption[] + currentValue?: string | null + error?: string +} + export type OpencodeReasoningEffortOption = { value: string name?: string diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index 0d745950..579fc959 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -6,6 +6,7 @@ import { isKnownFlavor, supportsEffort, supportsModelChange, + isCodexFamilyFlavor, } from './flavors' describe('hasCapability', () => { @@ -47,6 +48,11 @@ describe('hasCapability', () => { expect(hasCapability('kimi', Capabilities.Effort)).toBe(false) }) + test('grok supports runtime model and effort switching through ACP', () => { + expect(hasCapability('grok', Capabilities.ModelChange)).toBe(true) + expect(hasCapability('grok', Capabilities.Effort)).toBe(true) + }) + test('unknown flavor returns false', () => { expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false) }) @@ -66,6 +72,7 @@ describe('getFlavorLabel', () => { expect(getFlavorLabel('opencode')).toBe('OpenCode') expect(getFlavorLabel('pi')).toBe('Pi') expect(getFlavorLabel('kimi')).toBe('Kimi') + expect(getFlavorLabel('grok')).toBe('Grok Build') }) test('unknown flavor returns Unknown', () => { @@ -87,6 +94,7 @@ describe('isKnownFlavor', () => { expect(isKnownFlavor('opencode')).toBe(true) expect(isKnownFlavor('pi')).toBe(true) expect(isKnownFlavor('kimi')).toBe(true) + expect(isKnownFlavor('grok')).toBe(true) }) test('returns false for unknown/null/undefined', () => { @@ -97,6 +105,10 @@ describe('isKnownFlavor', () => { }) describe('convenience functions', () => { + test('treats Grok as a generic ACP/Codex-family permission flow', () => { + expect(isCodexFamilyFlavor('grok')).toBe(true) + }) + test('supportsModelChange matches hasCapability', () => { expect(supportsModelChange('claude')).toBe(true) expect(supportsModelChange('gemini')).toBe(true) @@ -113,6 +125,7 @@ describe('convenience functions', () => { expect(supportsEffort('codex')).toBe(false) expect(supportsEffort('gemini')).toBe(false) expect(supportsEffort('pi')).toBe(true) + expect(supportsEffort('grok')).toBe(true) expect(supportsEffort('kimi')).toBe(false) expect(supportsEffort(null)).toBe(false) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 15c59df3..c6ace530 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -13,6 +13,7 @@ const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), kimi: new Set([Capabilities.ModelChange]), + grok: new Set([Capabilities.ModelChange, Capabilities.Effort]), codex: new Set([Capabilities.ModelChange]), cursor: new Set([Capabilities.ModelChange]), opencode: new Set([Capabilities.ModelChange]), @@ -24,6 +25,7 @@ const FLAVOR_LABELS: Record = { claude: 'Claude', gemini: 'Gemini', kimi: 'Kimi', + grok: 'Grok Build', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', @@ -55,5 +57,9 @@ export function supportsEffort(flavor: string | null | undefined): boolean { } export function isCodexFamilyFlavor(flavor: string | null | undefined): boolean { - return flavor === 'codex' || flavor === 'gemini' || flavor === 'kimi' || flavor === 'opencode' + return flavor === 'codex' + || flavor === 'gemini' + || flavor === 'grok' + || flavor === 'kimi' + || flavor === 'opencode' } diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 53e23be4..db4534ed 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -29,6 +29,15 @@ describe('Gemini CLI sunset (read-only, not creatable)', () => { }) describe('getPermissionModesForFlavor', () => { + test("returns the conservative Grok modes", () => { + expect(getPermissionModesForFlavor('grok')).toEqual([ + 'default', + 'auto', + 'plan', + 'bypassPermissions' + ]) + }) + test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => { expect(getPermissionModesForFlavor('pi')).toEqual([]) }) @@ -54,6 +63,15 @@ describe('getPermissionModeOptionsForFlavor', () => { }) describe('isPermissionModeAllowedForFlavor', () => { + test("allows only the supported Grok modes", () => { + expect(isPermissionModeAllowedForFlavor('default', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('plan', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('bypassPermissions', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('acceptEdits', 'grok')).toBe(false) + expect(isPermissionModeAllowedForFlavor('auto', 'grok')).toBe(true) + expect(isPermissionModeAllowedForFlavor('yolo', 'grok')).toBe(false) + }) + test("no mode is allowed for pi", () => { expect(isPermissionModeAllowedForFlavor('yolo', 'pi')).toBe(false) expect(isPermissionModeAllowedForFlavor('default', 'pi')).toBe(false) diff --git a/shared/src/modes.ts b/shared/src/modes.ts index c4e0125c..8c5e4507 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode', 'pi'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'grok', 'kimi', 'opencode', 'pi'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -34,6 +34,9 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] export const KIMI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number] +export const GROK_PERMISSION_MODES = ['default', 'auto', 'plan', 'bypassPermissions'] as const +export type GrokPermissionMode = typeof GROK_PERMISSION_MODES[number] + export const OPENCODE_PERMISSION_MODES = ['default', 'plan', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] @@ -124,6 +127,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'kimi') { return KIMI_PERMISSION_MODES } + if (flavor === 'grok') { + return GROK_PERMISSION_MODES + } if (flavor === 'opencode') { return OPENCODE_PERMISSION_MODES } diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index c4284451..13d22794 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -30,6 +30,9 @@ export const RPC_METHODS = { ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd', + ListGrokModelsForCwd: 'listGrokModelsForCwd', + ListGrokModels: 'listGrokModels', + ListGrokReasoningEffortOptions: 'listGrokReasoningEffortOptions', ListOpencodeReasoningEffortOptions: 'listOpencodeReasoningEffortOptions' } as const diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 58b1a8ae..26961e72 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -37,6 +37,7 @@ export const MetadataSchema = z.object({ codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), + grokSessionId: z.string().optional(), cursorSessionId: z.string().optional(), cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(), // Drives the web `CursorMigrationBanner`: diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index c490c1bc..67bd8319 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -65,6 +65,18 @@ describe('getPendingRequestKinds', () => { }) describe('toSessionSummary', () => { + it('uses grokSessionId as the native resume token', () => { + const summary = toSessionSummary(makeSession({ + metadata: { + path: '/proj', + host: 'local', + flavor: 'grok', + grokSessionId: 'grok-session-1' + } + })) + expect(summary.metadata?.agentSessionId).toBe('grok-session-1') + }) + it('includes pending request kinds and background task count', () => { const summary = toSessionSummary(makeSession({ backgroundTaskCount: 2, diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 32a3a48c..bfa38faa 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -119,6 +119,7 @@ export function toSessionSummary(session: Session): SessionSummary { ?? session.metadata.claudeSessionId ?? session.metadata.geminiSessionId ?? session.metadata.opencodeSessionId + ?? session.metadata.grokSessionId ?? session.metadata.cursorSessionId ?? session.metadata.kimiSessionId ?? undefined, diff --git a/shared/src/slashCommands.ts b/shared/src/slashCommands.ts index b0c06976..33ff4a30 100644 --- a/shared/src/slashCommands.ts +++ b/shared/src/slashCommands.ts @@ -32,6 +32,14 @@ export const BUILTIN_SLASH_COMMANDS = { { name: 'compress', description: 'Compress the context by replacing it with a summary', source: 'builtin' }, { name: 'stats', description: 'Check session stats', source: 'builtin' }, ], + grok: [ + { name: 'compact', description: 'Compress conversation history to save context', source: 'builtin' }, + { name: 'context', description: 'Show context window usage and session stats', source: 'builtin' }, + { name: 'session-info', description: 'Show Grok session model, turns, and context usage', source: 'builtin' }, + { name: 'goal', description: 'Set, manage, or inspect an autonomous goal', source: 'builtin' }, + { name: 'always-approve', description: 'Toggle automatic tool approval', source: 'builtin' }, + { name: 'auto', description: 'Let Grok classify safe tool calls for automatic approval', source: 'builtin' }, + ], opencode: [ { name: 'help', description: 'Show supported HAPI OpenCode slash commands', source: 'builtin' }, { name: 'status', description: 'Show current OpenCode session config', source: 'builtin' }, diff --git a/shared/src/types.ts b/shared/src/types.ts index 63ffb71d..ffe12c6d 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -37,6 +37,7 @@ export type { CodexPermissionMode, CursorPermissionMode, GeminiPermissionMode, + GrokPermissionMode, KimiPermissionMode, OpencodePermissionMode, PermissionMode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d37e0828..dbbdcd87 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -31,6 +31,8 @@ import type { DeleteUploadResponse, FileReadResponse, GitCommandResponse, + GrokModelsResponse, + GrokReasoningEffortResponse, ListDirectoryResponse, MachineListDirectoryResponse, MachinePathsExistsResponse, @@ -596,11 +598,22 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + permissionMode?: PermissionMode ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ + directory, + agent, + model, + modelReasoningEffort, + yolo, + sessionType, + worktreeName, + effort, + permissionMode + }) }) } @@ -654,6 +667,24 @@ export class ApiClient { ) } + async getMachineGrokModelsForCwd(machineId: string, cwd: string): Promise { + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/grok-models?cwd=${encodeURIComponent(cwd)}` + ) + } + + async getSessionGrokModels(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/grok-models` + ) + } + + async getSessionGrokReasoningEffortOptions(sessionId: string): Promise { + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/grok-reasoning-effort-options` + ) + } + async getSlashCommands(sessionId: string): Promise { return await this.request( `/api/sessions/${encodeURIComponent(sessionId)}/slash-commands` diff --git a/web/src/components/AgentFlavorIcon.tsx b/web/src/components/AgentFlavorIcon.tsx index 796bea95..99438fb6 100644 --- a/web/src/components/AgentFlavorIcon.tsx +++ b/web/src/components/AgentFlavorIcon.tsx @@ -19,6 +19,10 @@ const FLAVOR_BADGES: Record = { label: 'Km', colors: 'bg-[#7c3aed] text-white', }, + grok: { + label: 'Gr', + colors: 'bg-[#111111] text-white', + }, pi: { label: 'Pi', colors: 'bg-[#5b21b6] text-white', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index d5033cc4..9e28c9a7 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -159,6 +159,7 @@ export function HappyComposer(props: { * disambiguates when two providers share a modelId). */ piSelectedModel?: { provider: string; modelId: string } | null availableModelReasoningEffortOptions?: Array<{ value: string; name?: string }> + availableEffortOptions?: Array<{ value: string; name?: string }> /** Cursor: selected base model key (not wire id). */ selectedModelBase?: string | null /** Cursor: selected variant sku/wire for highlight when session stores an ACP wire id. */ @@ -226,6 +227,7 @@ export function HappyComposer(props: { piModels, piSelectedModel, availableModelReasoningEffortOptions, + availableEffortOptions, selectedModelBase, selectedModelVariant, modelEffortOptions, @@ -508,8 +510,16 @@ export function HappyComposer(props: { const claudeEffortOptions = useMemo( () => agentFlavor === 'pi' ? getPiThinkingLevelOptions(effort, selectedPiModel?.thinkingLevelMap) + : agentFlavor === 'grok' && availableEffortOptions && availableEffortOptions.length > 0 + ? [ + { value: null, label: 'Default' }, + ...availableEffortOptions.map((option) => ({ + value: option.value, + label: option.name ?? option.value + })) + ] : getClaudeComposerEffortOptions(effort), - [agentFlavor, effort, selectedPiModel] + [agentFlavor, effort, selectedPiModel, availableEffortOptions] ) const permissionModes = useMemo( () => permissionModeOptions.map((option) => option.mode), diff --git a/web/src/components/AssistantChat/modelOptions.test.ts b/web/src/components/AssistantChat/modelOptions.test.ts index a7900cec..438a0cc6 100644 --- a/web/src/components/AssistantChat/modelOptions.test.ts +++ b/web/src/components/AssistantChat/modelOptions.test.ts @@ -154,6 +154,16 @@ describe('getModelOptionsForFlavor', () => { { value: 'claude-sonnet-4-5', label: 'claude-sonnet-4-5' } ]) }) + + it('returns only default/current for grok without falling back to Claude models', () => { + expect(getModelOptionsForFlavor('grok')).toEqual([ + { value: null, label: 'Default' } + ]) + expect(getModelOptionsForFlavor('grok', 'grok-4.5')).toEqual([ + { value: null, label: 'Default' }, + { value: 'grok-4.5', label: 'grok-4.5' } + ]) + }) }) describe('getNextModelForFlavor', () => { @@ -219,6 +229,10 @@ describe('getNextModelForFlavor', () => { expect(next).toBe('claude-sonnet-4-5') }) + it('keeps the current grok model on cycle (no Claude fallback)', () => { + expect(getNextModelForFlavor('grok', 'grok-4.5')).toBe('grok-4.5') + }) + it('returns null for pi without a current model (no Claude fallback)', () => { const next = getNextModelForFlavor('pi', null) expect(next).toBeNull() diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 9f350ecd..7e4aed68 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -126,6 +126,9 @@ export function getModelOptionsForFlavor( if (flavor === 'kimi') { return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) } + if (flavor === 'grok') { + return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) + } // Pi model list is provided dynamically via piModels prop in SessionChat, // not through this function. Show just the auto/default option here to // prevent falling through to the Claude preset cycler (which would @@ -175,6 +178,9 @@ export function getNextModelForFlavor( if (flavor === 'kimi') { return normalizeCurrentModel(currentModel) } + if (flavor === 'grok') { + return normalizeCurrentModel(currentModel) + } // Pi model list is provided dynamically via piModels prop — pressing // Ctrl/Cmd+M must not fall through to the Claude preset cycler. if (flavor === 'pi') { diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 1de5d7a4..95d693a4 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -1,4 +1,4 @@ -import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol' +import { CREATABLE_AGENT_FLAVORS, getFlavorLabel } from '@hapi/protocol' import type { AgentType } from './types' import { useTranslation } from '@/lib/use-translation' @@ -29,7 +29,7 @@ export function AgentSelector(props: { disabled={props.isDisabled} className="accent-[var(--app-link)]" /> - {agentType} + {getFlavorLabel(agentType)} ))} diff --git a/web/src/components/NewSession/GrokPermissionModeSelector.test.tsx b/web/src/components/NewSession/GrokPermissionModeSelector.test.tsx new file mode 100644 index 00000000..519b744a --- /dev/null +++ b/web/src/components/NewSession/GrokPermissionModeSelector.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { I18nProvider } from '@/lib/i18n-context' +import { GrokPermissionModeSelector } from './GrokPermissionModeSelector' + +describe('GrokPermissionModeSelector', () => { + it('offers Auto when Grok advertises the account feature', () => { + render( + + ) + + expect(screen.getByRole('option', { name: 'Auto' })).not.toBeDisabled() + }) + + it('shows Auto as unavailable when Grok does not advertise it', () => { + render( + + ) + + expect(screen.getByRole('option', { name: 'Auto (unavailable)' })).toBeDisabled() + expect(screen.getByText(/did not enable Auto permissions/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/components/NewSession/GrokPermissionModeSelector.tsx b/web/src/components/NewSession/GrokPermissionModeSelector.tsx new file mode 100644 index 00000000..0d72f71e --- /dev/null +++ b/web/src/components/NewSession/GrokPermissionModeSelector.tsx @@ -0,0 +1,47 @@ +import { + getPermissionModeOptionsForFlavor, + type GrokPermissionMode +} from '@hapi/protocol' +import { useTranslation } from '@/lib/use-translation' +import type { AgentType } from './types' + +export function GrokPermissionModeSelector(props: { + agent: AgentType + value: GrokPermissionMode + autoPermissionModeSupported: boolean | null + isDisabled: boolean + onChange: (value: GrokPermissionMode) => void +}) { + const { t } = useTranslation() + + if (props.agent !== 'grok') return null + + return ( +
+ + + {props.autoPermissionModeSupported === false ? ( + + {t('newSession.grokAutoUnavailableDesc')} + + ) : null} +
+ ) +} diff --git a/web/src/components/NewSession/LaunchEffortSelector.test.tsx b/web/src/components/NewSession/LaunchEffortSelector.test.tsx new file mode 100644 index 00000000..bffcacbd --- /dev/null +++ b/web/src/components/NewSession/LaunchEffortSelector.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +import { LaunchEffortSelector } from './LaunchEffortSelector' + +describe('LaunchEffortSelector', () => { + it('renders Grok low/medium/high effort and forwards the selection', () => { + const onChange = vi.fn() + const { container } = render( + + ) + const select = container.querySelector('select') as HTMLSelectElement + + expect(Array.from(select.options).map((option) => option.value)).toEqual([ + 'auto', 'low', 'medium', 'high' + ]) + fireEvent.change(select, { target: { value: 'low' } }) + expect(onChange).toHaveBeenCalledWith('low') + }) +}) diff --git a/web/src/components/NewSession/ClaudeEffortSelector.tsx b/web/src/components/NewSession/LaunchEffortSelector.tsx similarity index 66% rename from web/src/components/NewSession/ClaudeEffortSelector.tsx rename to web/src/components/NewSession/LaunchEffortSelector.tsx index 08e8e699..97e1c569 100644 --- a/web/src/components/NewSession/ClaudeEffortSelector.tsx +++ b/web/src/components/NewSession/LaunchEffortSelector.tsx @@ -1,19 +1,24 @@ -import type { AgentType, ClaudeEffort } from './types' -import { CLAUDE_EFFORT_OPTIONS } from './types' +import type { AgentType, LaunchEffort } from './types' +import { CLAUDE_EFFORT_OPTIONS, GROK_EFFORT_OPTIONS } from './types' import { useTranslation } from '@/lib/use-translation' -export function ClaudeEffortSelector(props: { +export function LaunchEffortSelector(props: { agent: AgentType - effort: ClaudeEffort + effort: LaunchEffort isDisabled: boolean - onEffortChange: (value: ClaudeEffort) => void + onEffortChange: (value: LaunchEffort) => void + grokOptions?: Array<{ value: string; label: string }> }) { const { t } = useTranslation() - if (props.agent !== 'claude') { + if (props.agent !== 'claude' && props.agent !== 'grok') { return null } + const options = props.agent === 'grok' + ? (props.grokOptions ?? GROK_EFFORT_OPTIONS) + : CLAUDE_EFFORT_OPTIONS + return (