feat(grok): add Grok Build (xAI) as a seventh CLI run mode

SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.

Grok mixes two existing shapes and the wiring follows from that:

- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
  (--always-approve, grok's bypassPermissions mode; config-level deny rules
  still apply on top). The Run button sends it true, like runAntigravity(),
  and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
  a bare grok spawn is grok's own ask-mode default, which is already safe,
  so only a sent config needs the flag forced off. Cron needs nothing for
  the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
  with mouse support, so it stays OUT of isAltScreenStripMode() and lands
  on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
  (unmeasured against an authenticated composer; documented fallback is the
  'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
  also installs a grok bin), so grok-cli-resolver.ts version-probes every
  candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
  GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
  shared with the dependency registry so doctor and run mode cannot drift.

Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.

Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.

Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).

Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-23 08:39:03 +02:00
parent 88bb98de43
commit 3f8c8e99d1
55 changed files with 1474 additions and 119 deletions
+24
View File
@@ -8,6 +8,7 @@
*/
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
@@ -139,6 +140,29 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
},
],
},
{
id: 'grok',
label: 'Grok CLI',
category: 'core',
required: false,
usedBy: ['Grok sessions'],
// Version match required for the same reason as pi: `grok` has known squatters
// (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a
// bare `which grok` hit is not the coding agent. Both sides share
// GROK_VERSION_REGEX, so the doctor and the run mode cannot drift.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['grok'],
versionArg: '--version',
versionRegex: GROK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
id: 'libreoffice',
label: 'LibreOffice',
+2 -1
View File
@@ -48,7 +48,8 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
* is NOT a clamp.
* Codex and antigravity need nothing here: their absent config already spawns safe.
* Codex, antigravity and grok need nothing here: their absent config already spawns safe
* (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent).
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
*/
export function clampCronExternalCliConfigs(
+11
View File
@@ -145,6 +145,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
gemini: 'exec gemini',
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
};
return commands[mode as DockerCommandMode] || commands.shell;
}
@@ -614,6 +615,16 @@ const CRED_STORES: CredStorePolicy[] = [
rel: '.pi/agent',
seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'],
},
// Grok (xAI) keeps auth + config in `~/.grok`, but that dir ALSO holds
// `sessions/`, `memory/`, `downloads/` (the ~100MB binary itself) and `bin/`,
// so seedWhole would copy all of it into every container start. Seed only what
// grok needs to authenticate and behave consistently. Same trade-off as pi:
// in-container grok sessions are invisible host-side, so `grok -c` inside a
// Docker case only sees that container's own history.
{
rel: '.grok',
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
},
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
];
+3
View File
@@ -19,6 +19,7 @@ import type {
GeminiConfig,
AntigravityConfig,
PiConfig,
GrokConfig,
SessionRemote,
SessionDocker,
} from './types.js';
@@ -78,6 +79,7 @@ export interface CreateSessionOptions {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -110,6 +112,7 @@ export interface RespawnPaneOptions {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
+1
View File
@@ -114,6 +114,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
gemini: remoteLoginShellCommand('gemini'),
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
+35 -4
View File
@@ -51,6 +51,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -171,7 +172,14 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi';
return (
mode === 'opencode' ||
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok'
);
}
function getModeLabel(mode: SessionMode): string {
@@ -186,6 +194,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Antigravity';
case 'pi':
return 'Pi';
case 'grok':
return 'Grok';
case 'shell':
return 'Shell';
case 'claude':
@@ -202,8 +212,9 @@ function getModeLabel(mode: SessionMode): string {
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode` (renders its own
* TUI that may rely on it) and `pi` (below). Keep parity with the replay-side
* strip in session-routes.ts.
* TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI
* with mouse support, i.e. the opencode case, not the Ink case). Keep parity
* with the replay-side strip in session-routes.ts.
*
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
@@ -508,6 +519,8 @@ export class Session extends EventEmitter {
private _antigravityConfig: AntigravityConfig | undefined;
// Pi configuration (only for mode === 'pi')
private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -603,6 +616,8 @@ export class Session extends EventEmitter {
antigravityConfig?: AntigravityConfig;
/** Pi configuration (only for mode === 'pi') */
piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -712,6 +727,11 @@ export class Session extends EventEmitter {
this._piConfig = config.piConfig;
}
// Apply Grok configuration
if (config.grokConfig) {
this._grokConfig = config.grokConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -1304,6 +1324,7 @@ export class Session extends EventEmitter {
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1476,7 +1497,11 @@ export class Session extends EventEmitter {
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(
this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity' || this.mode === 'pi'
this.mode === 'codex' ||
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok'
),
})
);
@@ -1546,6 +1571,7 @@ export class Session extends EventEmitter {
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1804,6 +1830,7 @@ export class Session extends EventEmitter {
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1893,6 +1920,10 @@ export class Session extends EventEmitter {
if (this.mode === 'pi') {
throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.');
}
// Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv
if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
+65 -2
View File
@@ -52,6 +52,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -92,6 +93,8 @@ import {
getAntigravityNotFoundMessage,
resolvePiDir,
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -802,6 +805,47 @@ function buildPiCommand(config?: PiConfig): string {
return parts.join(' ');
}
/**
* Build the Grok Build CLI (xAI `grok`) command with appropriate flags.
*
* The bypass switch is `--always-approve` ("auto-approve all tool executions",
* grok's `bypassPermissions` permission mode; config-level deny rules still
* apply on top). Absent config spawns bare `grok`, i.e. grok's own default
* ask-mode, which is why the multi-user clamp only needs the only-if-sent
* branch for grok. Flag surface verified against grok 1.0.5.
*
* `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through
* socket-scoped `tmux setenv` (envOverrides), never the spawn command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildGrokCommand(config?: GrokConfig): string {
const parts = ['grok'];
if (config?.alwaysApprove) {
parts.push('--always-approve');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// --resume and -c conflict; a valid explicit session id wins. Ids only:
// grok's --resume also accepts session TITLES, which are arbitrary user
// strings, so the id regex doubles as the no-titles rule here.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.continueSession) {
parts.push('--continue');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -845,6 +889,7 @@ export function buildSpawnCommand(options: {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -894,6 +939,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'pi') {
return buildPiCommand(options.piConfig);
}
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1109,6 +1157,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --conversation ${resumeId}`;
case 'pi':
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1699,10 +1749,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi'
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' ? ['unset NO_COLOR'] : []),
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
@@ -1797,6 +1849,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolvePiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'grok') {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1846,6 +1902,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1906,6 +1963,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1920,6 +1980,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2144,6 +2205,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2173,6 +2235,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
effort,
sessionName: name,
+1
View File
@@ -1013,6 +1013,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
{ id: 'gemini', label: 'gemini', detail: 'Google Gemini' },
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
];
// ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -149,7 +149,7 @@ export type TuiAnswerResult =
export interface TuiQuickStartOptions {
caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi';
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string;
+31 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -16,6 +16,7 @@
* - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -44,11 +45,11 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi';
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
>;
/**
@@ -157,7 +158,7 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -363,6 +364,30 @@ export interface PiConfig {
approveProjectTrust?: boolean;
}
/**
* Grok Build CLI (xAI `grok`) session configuration.
*
* Grok has Claude-style permission modes; the bypass switch is `--always-approve`
* ("auto-approve all tool executions", the CLI's `bypassPermissions` mode). Deny
* rules from `~/.grok/config.toml` / project `.grok/config.toml` still apply on
* top of it. Verified against grok 1.0.5.
*/
export interface GrokConfig {
/** Model ID (e.g. "grok-4.5", or a custom `[model.<name>]` from config.toml). Passed via --model. */
model?: string;
/**
* Auto-approve all tool executions (passes --always-approve). Absent = grok's
* own default permission mode (ask). Multi-user: forced off for non-granted
* owners by the only-if-sent clamp branch, like codex/antigravity — the
* absent-config spawn already defaults safe.
*/
alwaysApprove?: boolean;
/** Continue the most recent session for the working directory (-c). Skipped when resumeSessionId is set. */
continueSession?: boolean;
/** Resume a specific session by ID (--resume). Ids only, never titles or paths. */
resumeSessionId?: string;
}
/**
* Configuration for creating a new session
*/
@@ -526,6 +551,8 @@ export interface SessionState {
antigravityConfig?: AntigravityConfig;
/** Pi-specific configuration (only for mode === 'pi') */
piConfig?: PiConfig;
/** Grok-specific configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
*
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi): the server process PATH first, then the
* One lookup chain behind all seven *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi, grok): the server process PATH first, then the
* CLI's common install directories in order, then — last, because it is the
* only step that spawns anything — an interactive login shell, which is what
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
+151
View File
@@ -0,0 +1,151 @@
/**
* @fileoverview Resolve the Grok Build CLI (`grok`, xAI) binary across common install paths.
*
* Mirrors pi-cli-resolver.ts, version probe included: `grok` is another short
* name with known squatters (the unrelated `@vibe-kit/grok-cli` npm package also
* installs a `grok` bin), so a `which grok` hit is not by itself evidence that
* xAI's coding agent is installed. Every candidate is sanity-probed with
* `grok --version` and required to print a version-shaped string (the real CLI
* prints `grok 1.0.5 (5115b46bc9)`); a binary that fails the probe is treated
* as absent and the rejected path is logged. The probe cannot tell two
* version-printing `grok`s apart, which is why `GET /api/grok/status` surfaces
* path AND version: a misresolution is diagnosable rather than presenting as
* "the mode just doesn't work".
*
* The official installer (`curl -fsSL https://x.ai/cli/install.sh | bash`)
* places the binary in `~/.grok/bin` and symlinks it into `~/.local/bin`, so
* those two head the search list.
*
* @module utils/grok-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/** Common directories where the Grok CLI binary may be installed */
const GROK_SEARCH_DIRS = [
join(homedir(), '.grok', 'bin'),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), 'bin'),
];
/**
* A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5).
*
* Exported and SHARED with the `grok` entry in `config/dependency-registry.ts`,
* so `codeman doctor` and the run mode cannot disagree about what counts as an
* installed grok (the same single-source rule as PI_VERSION_REGEX). Shape is
* dictated by the doctor's `extractVersion()` (first capture group, whole-output
* scan): hence a capturing group and a leading boundary instead of `^`. No `g`
* flag, so there is no shared `lastIndex` to reset.
*/
export const GROK_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
const GROK_NOT_FOUND = 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash';
/**
* Run `grok --version` on a candidate path and return the version token when it
* looks like the coding agent. Returns null for anything else: a missing
* binary, a non-zero exit, a hang (timeout), or output with no version-shaped
* token (which is how an unrelated `grok` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have grok installed, and since `grok` is a
* name with known squatters, this probe would EXECUTE whatever binary of that
* name the machine carries. The shared resolver host is already inert under
* vitest, so this gate is defense in depth for any opted-in host that still
* carries the default probe; tests drive resolution via
* `createGrokResolverForTest`, whose injected probe bypasses it. Pinned by
* test/grok-cli-resolver.test.ts.
*/
function probeGrokVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `grok` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
const candidate = GROK_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" printed ${JSON.stringify(out.slice(0, 80))}`);
} catch (err) {
console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" failed (${(err as Error).message})`);
}
return null;
}
type GrokVersionProbe = (binPath: string) => string | null;
function createGrokResolver(
host?: CliResolverHost,
versionProbe: GrokVersionProbe = probeGrokVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'grok',
searchDirs: GROK_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated Grok wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createGrokResolverForTest(host: CliResolverHost, versionProbe?: GrokVersionProbe, now?: () => number) {
return createGrokResolver(host, versionProbe ?? probeGrokVersion, now);
}
const grokResolver = createGrokResolver();
/**
* Finds the directory containing a verified `grok` binary.
* Checks the server PATH first, then the common install locations
* (`~/.grok/bin` leading, the official installer's target). Every candidate
* must pass the `grok --version` sanity probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveGrokDir(): string | null {
return grokResolver.resolve()?.directory ?? null;
}
/**
* Check if the Grok CLI is available on the system.
*/
export function isGrokAvailable(): boolean {
return resolveGrokDir() !== null;
}
export function getGrokNotFoundMessage(): string {
return formatCliNotFoundMessage(GROK_NOT_FOUND, grokResolver.diagnostics());
}
/**
* Version reported by the resolved `grok` binary, or null when grok is
* unavailable. Surfaced through `GET /api/grok/status` so a misresolution is
* diagnosable from the UI.
*/
export function getGrokCliVersion(): string | null {
return grokResolver.resolve()?.metadata ?? null;
}
+1
View File
@@ -45,5 +45,6 @@ export {
getAntigravityNotFoundMessage,
} from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
+9 -5
View File
@@ -2243,9 +2243,11 @@ class CodemanApp {
? 'Antigravity'
: mode === 'pi'
? 'Pi'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'grok'
? 'Grok'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -4725,7 +4727,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
<span class="tab-name" data-session-id="${id}">${escapeHtml(tabLabel)}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
@@ -6154,7 +6156,9 @@ class CodemanApp {
? 'Kill Tmux & Antigravity'
: session.mode === 'pi'
? 'Kill Tmux & Pi'
: 'Kill Tmux & Claude Code';
: session.mode === 'grok'
? 'Kill Tmux & Grok'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+1
View File
@@ -78,6 +78,7 @@ const HOME_SESSIONS_MODE_BADGE = {
gemini: 'gm',
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
};
Object.assign(CodemanApp.prototype, {
+1
View File
@@ -109,6 +109,7 @@
'Run Gemini': '运行 Gemini',
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
+10 -1
View File
@@ -430,6 +430,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Pi
</button>
<button class="welcome-btn welcome-btn-grok" id="welcomeGrokBtn" style="display: none;" onclick="app.setRunMode('grok'); app.runGrok()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Grok
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -607,6 +611,9 @@
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
<span class="run-mode-dot pi"></span>Pi
</button>
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -884,6 +891,7 @@
<option value="gemini">Gemini</option>
<option value="antigravity">Antigravity</option>
<option value="pi">Pi</option>
<option value="grok">Grok</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2616,6 +2624,7 @@
<option value="opencode" data-cli="opencode">OpenCode</option>
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -2756,7 +2765,7 @@
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
+1
View File
@@ -54,6 +54,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
+20
View File
@@ -972,6 +972,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(244, 114, 182, 0.5) !important;
}
/* Grok mode colors on mobile. Same `!important` rationale as the pi block above. */
.btn-toolbar.btn-run.mode-grok,
.btn-toolbar.btn-run-gear.mode-grok {
background: #1c1c1f !important;
border-color: rgba(212, 212, 216, 0.3) !important;
color: #f4f4f5 !important;
}
.btn-toolbar.btn-run.mode-grok:active,
.btn-toolbar.btn-run-gear.mode-grok:active {
background: #3f3f46 !important;
border-color: rgba(212, 212, 216, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -3055,6 +3069,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+1 -1
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+69 -6
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi),
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -403,6 +403,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'pi') {
return await this.runPi();
}
if (mode === 'grok') {
return await this.runGrok();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -468,7 +471,7 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
@@ -565,7 +568,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -1278,6 +1281,66 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Launch a Grok Build (xAI `grok`) session.
*
* Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends
* `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous
* work, so the Run button opts into grok's bypassPermissions mode
* (`--always-approve`; config-level deny rules still apply on top). The
* multi-user clamp forces it back off for non-granted owners server-side.
*/
async runGrok() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run grok on the OTHER side: skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Grok session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/grok/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'grok',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
grokConfig: { alwaysApprove: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start Grok');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1343,7 +1406,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1373,7 +1436,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -3114,7 +3177,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'claude'
? mode
: 'claude';
},
+1
View File
@@ -1199,6 +1199,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeAntigravityBtn', 'antigravity'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
+53 -1
View File
@@ -347,7 +347,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.history-view-all-btn,
.session-tab .tab-mode.gemini,
.session-tab .tab-mode.antigravity,
.session-tab .tab-mode.pi
.session-tab .tab-mode.pi,
.session-tab .tab-mode.grok
) {
color: var(--accent-d);
}
@@ -2246,6 +2247,11 @@ body.solo-mode .btn-lifecycle-log {
color: #f472b6;
}
.session-tab .tab-mode.grok {
background: rgba(212, 212, 216, 0.18);
color: #d4d4d8;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3610,6 +3616,23 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
/* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok
and .run-mode-dot.grok so the welcome action reads as the same backend. */
.welcome-btn-grok {
background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%);
border-color: rgba(212, 212, 216, 0.4);
color: #f4f4f5;
box-shadow: 0 2px 8px rgba(212, 212, 216, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-grok:hover {
background: linear-gradient(135deg, #1f1f23 0%, #3f3f46 55%, #52525b 100%);
box-shadow: 0 4px 20px rgba(212, 212, 216, 0.22), 0 0 40px rgba(161, 161, 170, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(228, 228, 231, 0.5);
color: #fafafa;
transform: translateY(-1px);
}
.welcome-btn-gemini {
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
border-color: rgba(96, 165, 250, 0.4);
@@ -4684,6 +4707,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fff1f7;
}
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-grok`). */
.btn-toolbar.btn-run.mode-grok,
.btn-toolbar.btn-run-gear.mode-grok {
background: linear-gradient(135deg, #131316 0%, #27272a 55%, #3f3f46 100%);
border-color: rgba(212, 212, 216, 0.5);
color: #f4f4f5;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-grok:hover,
.btn-toolbar.btn-run-gear.mode-grok:hover {
background: linear-gradient(135deg, #1f1f23 0%, #3f3f46 55%, #52525b 100%);
box-shadow: 0 0 12px rgba(212, 212, 216, 0.28), 0 2px 8px rgba(63, 63, 70, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(228, 228, 231, 0.6);
color: #fafafa;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -4767,6 +4809,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -14102,6 +14145,15 @@ html:not([data-skin="og"]) {
color: #fff1f7;
}
.btn-toolbar.btn-run.mode-pi:hover { box-shadow: 0 0 14px -2px rgba(244, 114, 182, 0.45); }
/* Grok keeps its charcoal identity on the non-og skins. Same specificity trap
as pi above: without this rule the generic `.btn-toolbar.btn-run` in this
nested block wins and grok renders as generic claude blue. */
.btn-toolbar.btn-run.mode-grok {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
color: #fafafa;
}
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
+1 -1
View File
@@ -2213,7 +2213,7 @@ Object.assign(CodemanApp.prototype, {
}
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/shell) + a LIVE pill.
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']);
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
+58 -13
View File
@@ -24,6 +24,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -331,21 +332,27 @@ export function _resetPasteRateBuckets(): void {
* session user could simply answer "yes" to in the terminal, so merely omitting
* `--approve` is not a clamp. Forcing `approveProjectTrust: false` makes
* buildPiCommand emit `--no-approve`, and the prompt never appears.
*
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
* (`--always-approve`), and an ABSENT config already spawns in grok's own
* ask-mode default, so only a sent config needs the flag forced off.
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig };
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
@@ -355,11 +362,13 @@ async function clampExternalCliBypassForOwner(
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
};
}
@@ -752,6 +761,7 @@ export function registerSessionRoutes(
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -841,6 +851,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
@@ -886,9 +902,11 @@ export function registerSessionRoutes(
? body.antigravityConfig?.model
: mode === 'pi'
? body.piConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'grok'
? body.grokConfig?.model
: mode !== 'shell'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -898,12 +916,14 @@ export function registerSessionRoutes(
geminiConfig: gatedGeminiConfig,
antigravityConfig: gatedAntigravityConfig,
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
} = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig
body.piConfig,
body.grokConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -921,6 +941,7 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -1152,6 +1173,7 @@ export function registerSessionRoutes(
session.mode !== 'gemini' &&
session.mode !== 'antigravity' &&
session.mode !== 'pi' &&
session.mode !== 'grok' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2686,6 +2708,7 @@ export function registerSessionRoutes(
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
envOverrides,
effort,
parentSessionId,
@@ -2734,6 +2757,7 @@ export function registerSessionRoutes(
geminiConfig ||
antigravityConfig ||
piConfig ||
grokConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2766,6 +2790,7 @@ export function registerSessionRoutes(
geminiConfig ||
antigravityConfig ||
piConfig ||
grokConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2868,6 +2893,14 @@ export function registerSessionRoutes(
}
}
// Check Grok availability if requested
if (mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -2914,8 +2947,15 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, Gemini, and Antigravity use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini' && mode !== 'antigravity' && mode !== 'pi') {
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems)
if (
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok'
) {
await writeHooksConfig(resolvedCasePath);
}
@@ -2987,6 +3027,7 @@ export function registerSessionRoutes(
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -3009,9 +3050,11 @@ export function registerSessionRoutes(
? antigravityConfig?.model
: mode === 'pi'
? piConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'grok'
? grokConfig?.model
: mode !== 'shell'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
@@ -3020,7 +3063,8 @@ export function registerSessionRoutes(
geminiConfig: qsGatedGeminiConfig,
antigravityConfig: qsGatedAntigravityConfig,
piConfig: qsGatedPiConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig);
grokConfig: qsGatedGrokConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
@@ -3038,6 +3082,7 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
envOverrides,
effort,
remote,
@@ -3088,7 +3133,7 @@ export function registerSessionRoutes(
});
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
} else {
// 'claude', 'opencode', 'codex', 'gemini', and 'antigravity' modes use startInteractive()
// every non-shell mode ('claude', the external CLIs) uses startInteractive()
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
+16 -1
View File
@@ -380,7 +380,7 @@ export function registerSystemRoutes(
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi)
// CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi, Grok)
// ═══════════════════════════════════════════════════════════════
// ========== Claude ==========
@@ -446,6 +446,21 @@ export function registerSystemRoutes(
};
});
// ========== Grok ==========
// Carries `version` on top of the sibling shape, same reason as pi: `grok` is a
// binary name with known squatters, so the resolver version-probes candidates and
// this endpoint is where a misresolution shows up (path + version) instead of
// presenting as "the mode just doesn't work".
app.get('/api/grok/status', async () => {
const { isGrokAvailable, resolveGrokDir, getGrokCliVersion } = await import('../../utils/grok-cli-resolver.js');
return {
available: isGrokAvailable(),
path: resolveGrokDir(),
version: getGrokCliVersion(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
+44 -5
View File
@@ -122,7 +122,17 @@ export const FileWriteSchema = z
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_', 'PI_'];
const ALLOWED_ENV_PREFIXES = [
'CLAUDE_CODE_',
'OPENCODE_',
'CODEX_',
'GEMINI_',
'GOOGLE_',
'ANTIGRAVITY_',
'PI_',
'GROK_',
'XAI_',
];
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
@@ -161,7 +171,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -300,6 +310,32 @@ const PiConfigSchema = z
})
.optional();
/**
* Schema for Grok Build CLI (xAI `grok`)-specific configuration.
*
* `alwaysApprove` maps to `--always-approve` (grok's bypassPermissions mode).
* An ABSENT config spawns bare `grok` = grok's own ask-mode default, so the
* multi-user clamp only needs the only-if-sent branch (like codex/antigravity).
*/
const GrokConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
alwaysApprove: z.boolean().optional(),
continueSession: z.boolean().optional(),
// Ids only: grok's --resume also matches session TITLES (arbitrary user
// strings), which this regex deliberately cannot express.
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
})
.optional();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -313,7 +349,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -329,6 +365,7 @@ export const CreateSessionSchema = z.object({
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -464,6 +501,7 @@ const RemoteCommandOverridesSchema = z
gemini: z.string().min(1).max(300).optional(),
antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -738,12 +776,13 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -1267,7 +1306,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+4
View File
@@ -1396,6 +1396,7 @@ export class WebServer extends EventEmitter {
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1405,6 +1406,7 @@ export class WebServer extends EventEmitter {
import('../utils/gemini-cli-resolver.js'),
import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
@@ -1415,6 +1417,7 @@ export class WebServer extends EventEmitter {
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
@@ -2683,6 +2686,7 @@ export class WebServer extends EventEmitter {
geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,