diff --git a/CLAUDE.md b/CLAUDE.md index 39bb2199..c78dcfee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the b Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs. -**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), and Gemini (Google) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'`). +**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), and Antigravity (`agy`, Google) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity'`). **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. @@ -122,10 +122,10 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) -- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) +- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) - **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort ` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts` - **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not -- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md` +- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. Resolver design pattern: `docs/opencode-integration.md` - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice - **`xterm-zerolag-input` is single-source** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/`, and is bundled into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` (dev, by `scripts/postinstall.js`) and `dist/.../vendor/` (prod, by `scripts/build.mjs`). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundle.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md` - **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md` @@ -193,7 +193,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All three **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini) +**External CLI modes (OpenCode, Codex, Gemini, Antigravity)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All four **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini) **Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) @@ -274,7 +274,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L | **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login | | **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) | | **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) | -| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`) | +| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`) | | **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS | **Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway). diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 5e8ca527..aafc3ed3 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -98,6 +98,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ usedBy: ['Gemini sessions'], resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }], }, + { + id: 'antigravity', + label: 'Antigravity CLI', + category: 'core', + required: false, + usedBy: ['Antigravity sessions'], + resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }], + }, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 328757b4..5320c11d 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -143,6 +143,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string { opencode: 'exec opencode', codex: 'exec codex', gemini: 'exec gemini', + antigravity: 'exec agy', }; return commands[mode as DockerCommandMode] || commands.shell; } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index d6e58d4e..75f44a63 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -17,6 +17,7 @@ import type { CodexConfig, EffortLevel, GeminiConfig, + AntigravityConfig, SessionRemote, SessionDocker, } from './types.js'; @@ -74,6 +75,7 @@ export interface CreateSessionOptions { openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; geminiConfig?: GeminiConfig; + antigravityConfig?: AntigravityConfig; /** 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. */ @@ -102,6 +104,7 @@ export interface RespawnPaneOptions { openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; geminiConfig?: GeminiConfig; + antigravityConfig?: AntigravityConfig; /** Resume a previous Claude conversation when respawning */ resumeSessionId?: string; /** Extra env vars exported before launching the CLI (preserved across respawns). */ diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 571924a3..54722c7b 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -68,6 +68,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string { opencode: 'exec opencode', codex: 'exec codex', gemini: 'exec gemini', + antigravity: 'exec agy', }; return commands[mode as RemoteCommandMode] || commands.shell; } diff --git a/src/session.ts b/src/session.ts index 604d9e9c..5cf4e1b8 100644 --- a/src/session.ts +++ b/src/session.ts @@ -49,6 +49,7 @@ import { type CodexConfig, type EffortLevel, type GeminiConfig, + type AntigravityConfig, type SessionRemote, type SessionDocker, } from './types.js'; @@ -140,7 +141,7 @@ 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'; + return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity'; } function getModeLabel(mode: SessionMode): string { @@ -151,6 +152,8 @@ function getModeLabel(mode: SessionMode): string { return 'Codex'; case 'gemini': return 'Gemini'; + case 'antigravity': + return 'Antigravity'; case 'shell': return 'Shell'; case 'claude': @@ -403,6 +406,8 @@ export class Session extends EventEmitter { private _codexConfig: CodexConfig | undefined; // Gemini configuration (only for mode === 'gemini') private _geminiConfig: GeminiConfig | undefined; + // Antigravity configuration (only for mode === 'antigravity') + private _antigravityConfig: AntigravityConfig | undefined; private _resumeSessionId: string | undefined; // Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux @@ -489,6 +494,8 @@ export class Session extends EventEmitter { codexConfig?: CodexConfig; /** Gemini configuration (only for mode === 'gemini') */ geminiConfig?: GeminiConfig; + /** Antigravity configuration (only for mode === 'antigravity') */ + antigravityConfig?: AntigravityConfig; /** Resume a previous Claude conversation (used after server reboot) */ resumeSessionId?: string; /** Extra env vars exported to the CLI at spawn time (no disk persistence) */ @@ -562,6 +569,11 @@ export class Session extends EventEmitter { this._geminiConfig = config.geminiConfig; } + // Apply Antigravity configuration + if (config.antigravityConfig) { + this._antigravityConfig = config.antigravityConfig; + } + // 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) @@ -1111,6 +1123,7 @@ export class Session extends EventEmitter { openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, // COD-118: runtime-only — surfaced so the frontend can require explicit user @@ -1276,9 +1289,9 @@ export class Session extends EventEmitter { cols: ptyCols, rows: ptyRows, cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker), - // COD-75: codex/gemini get COLORTERM=truecolor — mirrors buildEnvExports() + // COD-75: codex/gemini/antigravity 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'), + env: buildMuxAttachEnv(this.mode === 'codex' || this.mode === 'gemini' || this.mode === 'antigravity'), }); } catch (spawnErr) { console.error(`[Session] Failed to spawn PTY for ${options.spawnErrLabel}:`, spawnErr); @@ -1343,6 +1356,7 @@ export class Session extends EventEmitter { openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1515,6 +1529,7 @@ export class Session extends EventEmitter { openCodeConfig: this._openCodeConfig, codexConfig: this._codexConfig, geminiConfig: this._geminiConfig, + antigravityConfig: this._antigravityConfig, resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1596,6 +1611,10 @@ export class Session extends EventEmitter { if (this.mode === 'gemini') { throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.'); } + // Antigravity sessions require tmux for env override injection via setenv + if (this.mode === 'antigravity') { + throw new Error('Antigravity 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 diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index ca29478e..2747d8f2 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -43,6 +43,7 @@ import { type CodexConfig, type EffortLevel, type GeminiConfig, + type AntigravityConfig, type SessionRemote, type SessionDocker, type DockerCommandMode, @@ -69,6 +70,7 @@ import { resolveOpenCodeDir, resolveCodexDir, resolveGeminiDir, + resolveAntigravityDir, } from './utils/index.js'; import type { TerminalMultiplexer, @@ -682,6 +684,34 @@ function buildGeminiCommand(config?: GeminiConfig): string { return parts.join(' '); } +/** + * Build the Antigravity CLI (agy) command with appropriate flags. + * + * Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added + * when the config explicitly asks for it (the frontend sends it for parity with + * Codeman's Claude default; the multi-user clamp strips it for non-granted owners, + * and an ABSENT config stays at agy's own prompting default — safe like Codex). + */ +function buildAntigravityCommand(config?: AntigravityConfig): string { + const parts = ['agy']; + + if (config?.dangerouslySkipPermissions) { + parts.push('--dangerously-skip-permissions'); + } + + if (config?.model) { + const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; + if (safeModel) parts.push('--model', safeModel); + } + + if (config?.resumeConversationId) { + const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined; + if (safeId) parts.push('--conversation', safeId); + } + + return parts.join(' '); +} + /** * Build the spawn command for any session mode. * Shared by createSession() and respawnPane() to avoid duplication. @@ -709,6 +739,7 @@ export function buildSpawnCommand(options: { openCodeConfig?: OpenCodeConfig; codexConfig?: CodexConfig; geminiConfig?: GeminiConfig; + antigravityConfig?: AntigravityConfig; resumeSessionId?: string; effort?: EffortLevel; }): string { @@ -739,6 +770,9 @@ export function buildSpawnCommand(options: { if (options.mode === 'gemini') { return buildGeminiCommand(options.geminiConfig); } + if (options.mode === 'antigravity') { + return buildAntigravityCommand(options.antigravityConfig); + } return '$SHELL'; } @@ -917,6 +951,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri return `${modeCommand} --resume ${resumeId}`; case 'codex': return `${modeCommand} resume ${resumeId}`; + case 'antigravity': + return `${modeCommand} --conversation ${resumeId}`; default: return modeCommand; // shell / opencode: no resume } @@ -1485,8 +1521,10 @@ 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' ? 'export COLORTERM=truecolor' : 'unset COLORTERM', - ...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []), + mode === 'codex' || mode === 'gemini' || mode === 'antigravity' + ? 'export COLORTERM=truecolor' + : 'unset COLORTERM', + ...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' ? ['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, @@ -1569,6 +1607,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const dir = resolveGeminiDir(); return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } + if (mode === 'antigravity') { + const dir = resolveAntigravityDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } return { pathExport: '', dir: null }; } @@ -1616,6 +1658,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { openCodeConfig, codexConfig, geminiConfig, + antigravityConfig, resumeSessionId, envOverrides, effort, @@ -1667,6 +1710,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'gemini' && !cliDir) { throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli'); } + if (mode === 'antigravity' && !cliDir) { + throw new Error( + 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' + ); + } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -1679,6 +1727,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { openCodeConfig, codexConfig, geminiConfig, + antigravityConfig, resumeSessionId, effort, }); @@ -1901,6 +1950,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { openCodeConfig, codexConfig, geminiConfig, + antigravityConfig, resumeSessionId, envOverrides, effort, @@ -1938,6 +1988,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { openCodeConfig, codexConfig, geminiConfig, + antigravityConfig, resumeSessionId, effort, }); diff --git a/src/types/session.ts b/src/types/session.ts index 8305759c..5f5d6752 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -8,12 +8,13 @@ * - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionOutput — captured stdout/stderr/exitCode * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' - * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend) + * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' (which CLI backend) * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - SessionColor — visual differentiation color * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId) * - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession) + * - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId) * * Cross-domain relationships: * - SessionState.respawnConfig embeds RespawnConfig (respawn domain) @@ -42,9 +43,12 @@ 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'; +export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity'; -export type RemoteCommandMode = Extract; +export type RemoteCommandMode = Extract< + SessionMode, + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' +>; /** * Advanced SSH connection options shared by RemoteHost and SessionRemote. @@ -150,7 +154,10 @@ export interface RemoteSessionInfo { // into the same long-lived container. See `docs/docker-cases-plan.md`. /** Which CLI backends a Docker case can run (same set as remote). */ -export type DockerCommandMode = Extract; +export type DockerCommandMode = Extract< + SessionMode, + 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' +>; /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ export type DockerEngine = 'docker' | 'podman'; @@ -312,6 +319,16 @@ export interface GeminiConfig { resumeSession?: string; } +/** Antigravity CLI (agy) session configuration */ +export interface AntigravityConfig { + /** Model identifier. Passed via --model. */ + model?: string; + /** Auto-approve all tool permission requests (passes --dangerously-skip-permissions). Absent = agy's default prompting. */ + dangerouslySkipPermissions?: boolean; + /** Resume a previous conversation by ID (passed via --conversation). */ + resumeConversationId?: string; +} + /** * Configuration for creating a new session */ @@ -453,6 +470,8 @@ export interface SessionState { codexConfig?: CodexConfig; /** Gemini-specific configuration (only for mode === 'gemini') */ geminiConfig?: GeminiConfig; + /** Antigravity-specific configuration (only for mode === 'antigravity') */ + antigravityConfig?: AntigravityConfig; /** 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) */ diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts new file mode 100644 index 00000000..dc3466e1 --- /dev/null +++ b/src/utils/antigravity-cli-resolver.ts @@ -0,0 +1,65 @@ +/** + * @fileoverview Resolve the Antigravity CLI (`agy`) binary across common install paths. + * + * Mirrors gemini-cli-resolver.ts. Google's installer (antigravity.google/cli/install.sh) + * places the binary at ~/.local/bin/agy; the other locations cover manual installs. + * + * @module utils/antigravity-cli-resolver + */ + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; + +/** Common directories where the Antigravity CLI binary may be installed */ +const ANTIGRAVITY_SEARCH_DIRS = [ + join(homedir(), '.local', 'bin'), + join(homedir(), '.antigravity', 'bin'), + '/usr/local/bin', + join(homedir(), 'bin'), +]; + +/** Cached directory containing the agy binary (empty string = searched but not found) */ +let _antigravityDir: string | null = null; + +/** + * Finds the directory containing the `agy` binary. + * Checks `which agy` first, then falls back to common install locations. + * + * @returns Directory path, or null if not found + */ +export function resolveAntigravityDir(): string | null { + if (_antigravityDir !== null) return _antigravityDir || null; + + try { + const result = execSync('which agy', { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + if (result && existsSync(result)) { + _antigravityDir = dirname(result); + return _antigravityDir; + } + } catch { + // agy not in PATH, will check common locations + } + + for (const dir of ANTIGRAVITY_SEARCH_DIRS) { + if (existsSync(join(dir, 'agy'))) { + _antigravityDir = dir; + return _antigravityDir; + } + } + + _antigravityDir = ''; + return null; +} + +/** + * Check if the Antigravity CLI is available on the system. + */ +export function isAntigravityAvailable(): boolean { + return resolveAntigravityDir() !== null; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 82fa9a5c..90c9f5e8 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -30,3 +30,4 @@ export { findClaudeDir, getAugmentedPath, getClaudeCliVersion } from './claude-c export { resolveOpenCodeDir } from './opencode-cli-resolver.js'; export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js'; export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js'; +export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js'; diff --git a/src/web/public/app.js b/src/web/public/app.js index b59f354c..0b904577 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1984,7 +1984,7 @@ class CodemanApp { // Render conversation thread const mode = this.sessions.get(this.activeSessionId)?.mode; const agentLabel = - mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude'; + mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'antigravity' ? 'Antigravity' : mode === 'opencode' ? 'OpenCode' : 'Claude'; body.innerHTML = ''; for (const msg of messages) { const div = document.createElement('div'); @@ -3545,7 +3545,7 @@ class CodemanApp { - ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : mode === 'antigravity' ? '' : ''} ${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '' + escapeHtml(p.prefix) + ': ' + escapeHtml(p.suffix) + '' : escapeHtml(name); })()} @@ -4630,7 +4630,9 @@ class CodemanApp { ? 'Kill Tmux & Codex' : session.mode === 'gemini' ? 'Kill Tmux & Gemini' - : 'Kill Tmux & Claude Code'; + : session.mode === 'antigravity' + ? 'Kill Tmux & Antigravity' + : 'Kill Tmux & Claude Code'; } document.getElementById('closeConfirmModal').classList.add('active'); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 4ea11c50..8fd93d72 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -103,6 +103,7 @@ 'Run Claude Code': '运行 Claude Code', 'Run OpenCode': '运行 OpenCode', 'Run Gemini': '运行 Gemini', + 'Run Antigravity': '运行 Antigravity', 'Run Shell': '运行 Shell', 'Select AI backend': '选择 AI 后端', 'Create New Case': '新建案例', diff --git a/src/web/public/index.html b/src/web/public/index.html index 5099a049..aa04f78d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -464,6 +464,9 @@ +