feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953)

* feat(gemini): remove launchable Gemini CLI agent, keep sessions readable

Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped
serving requests 2026-06-18). This removes the ability to launch/create
Gemini CLI sessions while keeping existing stored Gemini sessions fully
readable in the web UI.

Removed (no longer launchable):
- cli/src/gemini/ runtime (runGemini, loop, local/remote launchers,
  session, ACP backend, config, scanner) + GeminiDisplay ink view
- `hapi gemini` command + registry entry + usage line
- runner spawn branch & buildCliArgs mapping now reject gemini with a
  clear error; resume dispatch throws a clear "no longer supported" error
- gemini dropped from the new-session agent selector via new
  CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults

Kept (read path — existing sessions still validate, load, render):
- `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS
- AgentFlavorIcon badge, model-option labels, ACP message normalization,
  metadata.geminiSessionId, hub session dedup/resume-id

Note: the Gemini *Live voice* backend is a separate feature and is
untouched.

Adds read-guarantee tests (stored gemini validates; excluded from
creatable). typecheck + full suite green.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): reject gemini resume before handoff (#953 review)

HAPI Bot [Major]: `hapi resume <active-gemini-session>` called
handoffSessionToLocal() — which tells the running remote agent to exit —
before reaching the gemini-unsupported throw in dispatchLocalResume, so
it could stop the live/readable session and then fail locally.

Move the gemini guard into resumeCommand.run before the handoff, so an
active Gemini session is left running/readable instead of being stopped.
Keep the dispatch-layer guard as defense-in-depth. Adds a regression test
asserting handoffSessionToLocal is not called for an active gemini target.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): harden against stale gemini input (#953 review)

Two [Minor] follow-ups from HAPI Bot:
- newSessionFormDraft: coerce a restored browse draft's agent to a
  creatable flavor, so a pre-removal 'gemini' draft cannot submit
  agent:'gemini' even though the selector no longer offers it.
- buildCliArgs: reject 'gemini' explicitly instead of silently falling
  through to the 'claude' command if the exported helper is reused
  outside the guarded spawnSession path.

Updated the buildCliArgs precedence test to a creatable agent and added
a test asserting buildCliArgs('gemini') throws.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): reset dependent draft fields when coercing stale agent (#953 review)

Follow-up [Minor]: coercing a stale gemini draft's agent to claude left
model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' }
draft restored as claude *with* a Gemini model, which handleCreate() then
sent to the runner. Now reset model / cursorSelectedBase / effort /
modelReasoningEffort to defaults whenever the agent is coerced.

Adds a regression test.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review)

HAPI Bot [Major]: after removing geminiCommand from the registry,
resolveCommand() treats `gemini` as an unknown subcommand and falls
through to the default Claude command (forwarding "gemini" as an arg),
so `hapi gemini` silently started Claude instead of reporting the sunset.

Add an explicit tombstone `gemini` command that prints the sunset error
and exits 1.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(web): assert AgentSelector hides the sunset Gemini agent (#953)

Render regression test confirming the new-session AgentSelector offers
exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-29 11:42:41 +08:00
committed by GitHub
co-authored by HAPI Claude Opus 4.8
parent 26a24bb6ce
commit b44885ae67
28 changed files with 182 additions and 2170 deletions
-1
View File
@@ -88,7 +88,6 @@ ${chalk.bold('Usage:')}
hapi auth Manage authentication
hapi codex Start Codex mode
hapi cursor Start Cursor Agent mode
hapi gemini Start Gemini ACP mode
hapi opencode Start OpenCode ACP mode
hapi resume [id] Resume an existing HAPI session locally
hapi mcp Start MCP stdio bridge
-30
View File
@@ -1,30 +0,0 @@
import chalk from 'chalk'
import { authAndSetupMachineIfNeeded } from '@/ui/auth'
import { initializeToken } from '@/ui/tokenInit'
import { maybeAutoStartServer } from '@/utils/autoStartServer'
import type { CommandDefinition } from './types'
import { GEMINI_PERMISSION_MODES } from '@hapi/protocol/modes'
import { parseRemoteAgentCommandOptions } from './agentCommandOptions'
export const geminiCommand: CommandDefinition = {
name: 'gemini',
requiresRuntimeAssets: true,
run: async ({ commandArgs }) => {
try {
const options = parseRemoteAgentCommandOptions(commandArgs, GEMINI_PERMISSION_MODES)
await initializeToken()
await maybeAutoStartServer()
await authAndSetupMachineIfNeeded()
const { runGemini } = await import('@/gemini/runGemini')
await runGemini(options)
} catch (error) {
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')
if (process.env.DEBUG) {
console.error(error)
}
process.exit(1)
}
}
}
+18 -2
View File
@@ -1,3 +1,4 @@
import chalk from 'chalk'
import { authCommand } from './auth'
import { claudeCommand } from './claude'
import { codexCommand } from './codex'
@@ -6,7 +7,6 @@ import { connectCommand } from './connect'
import { runnerCommand } from './runner'
import { resumeCommand } from './resume'
import { doctorCommand } from './doctor'
import { geminiCommand } from './gemini'
import { kimiCommand } from './kimi'
import { opencodeCommand } from './opencode'
import { piCommand } from './pi'
@@ -16,12 +16,28 @@ import { notifyCommand } from './notify'
import { hubCommand } from './hub'
import type { CommandContext, CommandDefinition } from './types'
// Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on
// 2026-06-18) so the agent is no longer launchable. Keep an explicit tombstone
// command so `hapi gemini` reports a clear error instead of falling through to
// the default Claude command with "gemini" as a forwarded argument.
const removedGeminiCommand: CommandDefinition = {
name: 'gemini',
requiresRuntimeAssets: false,
run: async () => {
console.error(
chalk.red('Error:'),
'Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.'
)
process.exit(1)
}
}
const COMMANDS: CommandDefinition[] = [
authCommand,
connectCommand,
codexCommand,
cursorCommand,
geminiCommand,
removedGeminiCommand,
kimiCommand,
opencodeCommand,
piCommand,
+31
View File
@@ -142,6 +142,37 @@ describe('resumeCommand', () => {
})
})
it('rejects an active Gemini target before handoff (no longer supported, leaves running session alone)', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 'undefined'}`)
}) as never)
getLocalResumeTargetMock.mockResolvedValue({
sessionId: 'hapi-session-gemini',
flavor: 'gemini',
directory: '/tmp/project',
machineId: 'machine-1',
active: true,
thinking: false,
controlledByUser: false,
agentSessionId: 'gemini-conv-1',
model: 'gemini-2.5-pro',
permissionMode: 'default'
})
try {
await expect(resumeCommand.run(createContext(['hapi-session-gemini']))).rejects.toThrow('process.exit:1')
// Regression (#953): the gemini guard must fire BEFORE handoff so an
// active Gemini session is not stopped and then left failing locally.
expect(handoffSessionToLocalMock).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('no longer supported'))
} finally {
consoleErrorSpy.mockRestore()
exitSpy.mockRestore()
}
})
it('fails before launching when the target belongs to another machine', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
+9 -12
View File
@@ -7,7 +7,6 @@ import type {
ClaudePermissionMode,
CodexPermissionMode,
CursorPermissionMode,
GeminiPermissionMode,
KimiPermissionMode,
OpencodePermissionMode
} from '@hapi/protocol/types'
@@ -104,17 +103,7 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise<void> {
}
if (target.flavor === 'gemini') {
const { runGemini } = await import('@/gemini/runGemini')
await runGemini({
existingSessionId: base.existingSessionId,
workingDirectory: base.workingDirectory,
resumeSessionId: base.resumeSessionId,
startedBy: base.startedBy,
permissionMode: base.permissionMode as GeminiPermissionMode | undefined,
startingMode: 'local',
model: target.model ?? undefined
})
return
throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.')
}
if (target.flavor === 'opencode') {
@@ -209,6 +198,14 @@ export const resumeCommand: CommandDefinition = {
assertTargetMachine(target, machineId)
assertDirectoryExists(target)
// Gemini CLI is no longer launchable (Google sunset the consumer
// Gemini CLI on 2026-06-18). Reject BEFORE the handoff below so an
// active Gemini session is left running/readable rather than being
// stopped by handoffSessionToLocal and then failing locally.
if (target.flavor === 'gemini') {
throw new Error('Gemini CLI is no longer supported and cannot be resumed (Google sunset the consumer Gemini CLI on 2026-06-18). The session history remains viewable in the web UI.')
}
if (target.active && target.controlledByUser) {
throw new Error('Session is already controlled by a local terminal')
}
-51
View File
@@ -1,51 +0,0 @@
import { logger } from '@/ui/logger';
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
export async function geminiLocal(opts: {
path: string;
sessionId: string | null;
abort: AbortSignal;
model?: string;
approvalMode?: string;
allowedTools?: string[];
hookSettingsPath?: string;
}): Promise<void> {
const args: string[] = [];
if (opts.sessionId) {
args.push('--resume', opts.sessionId);
}
if (opts.model) {
args.push('--model', opts.model);
}
if (opts.approvalMode) {
args.push('--approval-mode', opts.approvalMode);
}
if (opts.allowedTools && opts.allowedTools.length > 0) {
args.push('--allowed-tools', ...opts.allowedTools);
}
const env: NodeJS.ProcessEnv = {
...process.env,
GEMINI_PROJECT_DIR: opts.path
};
if (opts.hookSettingsPath) {
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath;
}
logger.debug(`[GeminiLocal] Spawning gemini with args: ${JSON.stringify(args)}`);
await spawnWithTerminalGuard({
command: 'gemini',
args,
cwd: opts.path,
env,
signal: opts.abort,
shell: process.platform === 'win32',
logLabel: 'GeminiLocal',
spawnName: 'gemini',
installHint: 'Gemini CLI',
includeCause: true,
logExit: true
});
}
-104
View File
@@ -1,104 +0,0 @@
import { geminiLocal } from './geminiLocal';
import { GeminiSession } from './session';
import { createGeminiSessionScanner } from './utils/sessionScanner';
import type { PermissionMode } from './types';
import { randomUUID } from 'node:crypto';
import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher';
type GeminiScannerHandle = Awaited<ReturnType<typeof createGeminiSessionScanner>>;
function mapApprovalMode(mode: PermissionMode | undefined): string | undefined {
if (!mode || mode === 'default' || mode === 'read-only') {
return 'default';
}
if (mode === 'safe-yolo') {
return 'auto_edit';
}
return 'yolo';
}
export async function geminiLocalLauncher(
session: GeminiSession,
opts: {
model?: string;
allowedTools?: string[];
hookSettingsPath?: string;
}
): Promise<'switch' | 'exit'> {
const launcher = new BaseLocalLauncher({
label: 'gemini-local',
failureLabel: 'Local Gemini process failed',
queue: session.queue,
rpcHandlerManager: session.client.rpcHandlerManager,
startedBy: session.startedBy,
startingMode: session.startingMode,
launch: async (abortSignal) => {
await geminiLocal({
path: session.path,
sessionId: session.sessionId,
abort: abortSignal,
model: opts.model,
approvalMode: mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined),
allowedTools: opts.allowedTools,
hookSettingsPath: opts.hookSettingsPath
});
},
sendFailureMessage: (message) => {
session.sendSessionEvent({ type: 'message', message });
},
recordLocalLaunchFailure: (message, exitReason) => {
session.recordLocalLaunchFailure(message, exitReason);
}
});
let scanner: GeminiScannerHandle | null = null;
const handleTranscriptMessage = (message: { type?: string; content?: string }) => {
if (message.type === 'user' && typeof message.content === 'string') {
session.sendUserMessage(message.content);
return;
}
if (message.type === 'gemini' && typeof message.content === 'string') {
session.sendAgentMessage({
type: 'message',
message: message.content,
id: randomUUID()
});
}
};
const ensureScanner = async (transcriptPath: string): Promise<void> => {
if (scanner) {
scanner.onNewSession(transcriptPath);
return;
}
scanner = await createGeminiSessionScanner({
transcriptPath,
onMessage: handleTranscriptMessage,
onSessionId: (sessionId) => session.onSessionFound(sessionId)
});
};
const handleTranscriptPath = (transcriptPath: string) => {
void ensureScanner(transcriptPath);
};
const hadTranscriptPath = Boolean(session.transcriptPath);
if (hadTranscriptPath && session.transcriptPath) {
await ensureScanner(session.transcriptPath);
} else {
session.addTranscriptPathCallback(handleTranscriptPath);
}
try {
return await launcher.run();
} finally {
if (!hadTranscriptPath) {
session.removeTranscriptPathCallback(handleTranscriptPath);
}
if (scanner !== null) {
await (scanner as GeminiScannerHandle).cleanup();
}
}
}
-235
View File
@@ -1,235 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import type { GeminiMode, PermissionMode } from './types';
const harness = vi.hoisted(() => ({
setModelArgs: [] as Array<{ sessionId: string; modelId: string }>,
promptCount: 0,
events: [] as string[],
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>)
}));
vi.mock('./utils/geminiBackend', () => ({
createGeminiBackend: vi.fn(() => ({
initialize: vi.fn(async () => {}),
newSession: vi.fn(async () => 'acp-session-1'),
loadSession: vi.fn(async () => 'acp-session-1'),
setModel: vi.fn(async (sessionId: string, modelId: string) => {
harness.events.push(`setModel:${modelId}`);
harness.setModelArgs.push({ sessionId, modelId });
if (harness.setModelImpl) {
await harness.setModelImpl(sessionId, modelId);
}
}),
prompt: vi.fn(async () => {
harness.events.push('prompt:start');
harness.promptCount++;
await new Promise<void>((resolve) => setImmediate(resolve));
harness.events.push('prompt:end');
}),
cancelPrompt: vi.fn(async () => {}),
respondToPermission: vi.fn(async () => {}),
onStderrError: vi.fn(),
onPermissionRequest: vi.fn(),
disconnect: vi.fn(async () => {})
}))
}));
vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
buildHapiMcpBridge: async () => ({
server: { stop: () => {} },
mcpServers: {}
})
}));
vi.mock('./utils/permissionHandler', () => ({
GeminiPermissionHandler: class {
async cancelAll(): Promise<void> {}
}
}));
vi.mock('./utils/config', () => ({
resolveGeminiRuntimeConfig: () => ({
model: 'gemini-3-flash-preview',
modelSource: 'default'
})
}));
vi.mock('@/ui/ink/GeminiDisplay', () => ({
GeminiDisplay: () => null
}));
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn(),
warn: vi.fn(),
info: vi.fn()
}
}));
import { geminiRemoteLauncher } from './geminiRemoteLauncher';
function createMode(model?: string): GeminiMode {
return {
permissionMode: 'default' as PermissionMode,
model
};
}
function createSessionStub(items: Array<{ message: string; mode: GeminiMode }>) {
const queue = new MessageQueue2<GeminiMode>((mode) => JSON.stringify(mode));
items.forEach(({ message, mode }, index) => {
if (index === 0 && items.length > 1) {
queue.pushIsolateAndClear(message, mode);
} else {
queue.push(message, mode);
}
});
queue.close();
const sessionEvents: Array<{ type: string; [key: string]: unknown }> = [];
const rpcHandlers = new Map<string, (params: unknown) => unknown>();
const client = {
rpcHandlerManager: {
registerHandler(method: string, handler: (params: unknown) => unknown) {
rpcHandlers.set(method, handler);
}
},
sendAgentMessage(_message: unknown) {},
sendUserMessage(_text: string) {},
sendSessionEvent(event: { type: string; [key: string]: unknown }) {
sessionEvents.push(event);
}
};
const session = {
path: '/tmp/hapi-gemini-test',
logPath: '/tmp/hapi-gemini-test/test.log',
client,
queue,
sessionId: null as string | null,
thinking: false,
getPermissionMode() {
return 'default' as const;
},
setModel(_model: string | null) {},
onThinkingChange(thinking: boolean) {
session.thinking = thinking;
},
onSessionFound(id: string) {
session.sessionId = id;
},
sendAgentMessage(_message: unknown) {},
sendSessionEvent(event: { type: string; [key: string]: unknown }) {
client.sendSessionEvent(event);
},
sendUserMessage(_text: string) {}
};
return { session, sessionEvents, rpcHandlers };
}
describe('geminiRemoteLauncher inline model switch', () => {
afterEach(() => {
harness.setModelArgs = [];
harness.promptCount = 0;
harness.events = [];
harness.setModelImpl = null;
});
it('calls setModel between turns when the queued model differs from the running backend model', async () => {
const { session } = createSessionStub([
{ message: 'first', mode: createMode('gemini-3-flash-preview') },
{ message: 'second', mode: createMode('gemini-2.5-pro') }
]);
await geminiRemoteLauncher(session as never, {});
expect(harness.setModelArgs).toEqual([
{ sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' }
]);
expect(harness.promptCount).toBe(2);
});
it('does not call setModel when the model is unchanged across turns', async () => {
const { session } = createSessionStub([
{ message: 'first', mode: createMode('gemini-3-flash-preview') },
{ message: 'second', mode: createMode('gemini-3-flash-preview') }
]);
await geminiRemoteLauncher(session as never, {});
expect(harness.setModelArgs).toEqual([]);
expect(harness.promptCount).toBe(2);
});
it('latches inline switching off after a method-not-found response and notifies the user once', async () => {
harness.setModelImpl = async () => {
throw new Error('Method not found: session/set_model');
};
const { session, sessionEvents } = createSessionStub([
{ message: 'first', mode: createMode('gemini-3-flash-preview') },
{ message: 'second', mode: createMode('gemini-2.5-pro') },
{ message: 'third', mode: createMode('gemini-2.5-flash') }
]);
await geminiRemoteLauncher(session as never, {});
// Only one setModel attempt — latched off after the first method-not-found
expect(harness.setModelArgs).toEqual([
{ sessionId: 'acp-session-1', modelId: 'gemini-2.5-pro' }
]);
const unsupportedMessages = sessionEvents.filter(
(event) =>
event.type === 'message' &&
typeof event.message === 'string' &&
event.message.includes('does not support inline model switching')
);
expect(unsupportedMessages.length).toBe(1);
expect(harness.promptCount).toBe(3);
});
it('reports a transient setModel error and continues with the previous model', async () => {
let attempts = 0;
harness.setModelImpl = async () => {
attempts++;
throw new Error('Transient backend failure');
};
const { session, sessionEvents } = createSessionStub([
{ message: 'first', mode: createMode('gemini-3-flash-preview') },
{ message: 'second', mode: createMode('gemini-2.5-pro') }
]);
await geminiRemoteLauncher(session as never, {});
expect(attempts).toBe(1);
const failureMessages = sessionEvents.filter(
(event) =>
event.type === 'message' &&
typeof event.message === 'string' &&
event.message.includes('Failed to switch model')
);
expect(failureMessages.length).toBe(1);
expect(failureMessages[0]?.message).toContain('gemini-2.5-pro');
expect(harness.promptCount).toBe(2);
});
it('serializes setModel after the previous prompt resolves', async () => {
const { session } = createSessionStub([
{ message: 'first', mode: createMode('gemini-3-flash-preview') },
{ message: 'second', mode: createMode('gemini-2.5-pro') }
]);
await geminiRemoteLauncher(session as never, {});
// Order must be: prompt(1) start/end → setModel → prompt(2) start/end
expect(harness.events).toEqual([
'prompt:start',
'prompt:end',
'setModel:gemini-2.5-pro',
'prompt:start',
'prompt:end'
]);
});
});
-306
View File
@@ -1,306 +0,0 @@
import React from 'react';
import { logger } from '@/ui/logger';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import { convertAgentMessage } from '@/agent/messageConverter';
import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types';
import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase';
import { GeminiDisplay } from '@/ui/ink/GeminiDisplay';
import type { GeminiSession } from './session';
import type { PermissionMode } from './types';
import { createGeminiBackend } from './utils/geminiBackend';
import { GeminiPermissionHandler } from './utils/permissionHandler';
import { resolveGeminiRuntimeConfig } from './utils/config';
class GeminiRemoteLauncher extends RemoteLauncherBase {
private readonly session: GeminiSession;
private readonly model?: string;
private readonly hookSettingsPath?: string;
private backend: ReturnType<typeof createGeminiBackend> | null = null;
private permissionHandler: GeminiPermissionHandler | null = null;
private happyServer: { stop: () => void } | null = null;
private abortController = new AbortController();
private displayModel: string | null = null;
private displayPermissionMode: PermissionMode | null = null;
private currentBackendModel: string | null = null;
private setModelSupported: boolean | undefined = undefined;
constructor(session: GeminiSession, opts: { model?: string; hookSettingsPath?: string }) {
super(process.env.DEBUG ? session.logPath : undefined);
this.session = session;
this.model = opts.model;
this.hookSettingsPath = opts.hookSettingsPath;
}
public async launch(): Promise<RemoteLauncherExitReason> {
return this.start({
onExit: () => this.handleExitFromUi(),
onSwitchToLocal: () => this.handleSwitchFromUi()
});
}
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
return React.createElement(GeminiDisplay, context);
}
protected async runMainLoop(): Promise<void> {
const session = this.session;
const messageBuffer = this.messageBuffer;
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client);
this.happyServer = happyServer;
const runtimeConfig = resolveGeminiRuntimeConfig({ model: this.model });
this.displayModel = runtimeConfig.model;
messageBuffer.addMessage(`[MODEL:${runtimeConfig.model}]`, 'system');
const backend = createGeminiBackend({
model: runtimeConfig.model,
token: runtimeConfig.token,
hookSettingsPath: this.hookSettingsPath,
cwd: session.path,
permissionMode: session.getPermissionMode() as string | undefined
});
this.backend = backend;
backend.onStderrError((error) => {
logger.debug('[gemini-remote] stderr error', error);
session.sendSessionEvent({ type: 'message', message: error.message });
messageBuffer.addMessage(error.message, 'status');
});
await backend.initialize();
const resumeSessionId = session.sessionId;
const acpMcpServers = toAcpMcpServers(mcpServers);
let acpSessionId: string;
if (resumeSessionId) {
try {
acpSessionId = await backend.loadSession({
sessionId: resumeSessionId,
cwd: session.path,
mcpServers: acpMcpServers
});
} catch (error) {
logger.warn('[gemini-remote] resume failed, starting new session', error);
session.sendSessionEvent({
type: 'message',
message: 'Gemini resume failed; starting a new session.'
});
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: acpMcpServers
});
}
} else {
acpSessionId = await backend.newSession({
cwd: session.path,
mcpServers: acpMcpServers
});
}
session.onSessionFound(acpSessionId);
this.permissionHandler = new GeminiPermissionHandler(
session.client,
backend,
() => session.getPermissionMode() as PermissionMode | undefined
);
this.currentBackendModel = runtimeConfig.model;
this.applyDisplayMode(session.getPermissionMode() as PermissionMode, this.currentBackendModel);
this.setupAbortHandlers(session.client.rpcHandlerManager, {
onAbort: () => this.handleAbort(),
onSwitch: () => this.handleSwitchRequest()
});
const sendReady = () => {
session.sendSessionEvent({ type: 'ready' });
};
while (!this.shouldExit) {
const batch = await session.queue.waitForMessagesAndGetAsString(this.abortController.signal);
if (!batch) {
if (this.abortController.signal.aborted && !this.shouldExit) {
continue;
}
break;
}
// Inline model change via RPC. If the running gemini-cli build does not
// implement session/set_model, we learn that from the first method-not-found
// response and stop attempting it for the rest of this session.
if (batch.mode.model && batch.mode.model !== this.currentBackendModel) {
if (!backend.setModel || this.setModelSupported === false) {
batch.mode.model = this.currentBackendModel!;
} else {
logger.debug(`[gemini-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`);
try {
await backend.setModel(acpSessionId, batch.mode.model);
this.currentBackendModel = batch.mode.model;
this.setModelSupported = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const methodNotFound = /method not found/i.test(message);
if (methodNotFound && this.setModelSupported === undefined) {
this.setModelSupported = false;
logger.warn('[gemini-remote] Gemini CLI build does not support session/set_model; inline switching disabled for this session');
session.sendSessionEvent({
type: 'message',
message: 'This Gemini CLI build does not support inline model switching. Restart the session to apply a different model.'
});
} else {
logger.warn('[gemini-remote] Inline model switch failed', error);
session.sendSessionEvent({
type: 'message',
message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel}.`
});
}
batch.mode.model = this.currentBackendModel!;
}
}
}
this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model);
messageBuffer.addMessage(batch.message, 'user');
const promptContent: PromptContent[] = [{
type: 'text',
text: batch.message
}];
session.onThinkingChange(true);
try {
await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => {
this.handleAgentMessage(message);
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn('[gemini-remote] prompt failed', { message: errorMessage });
session.sendSessionEvent({
type: 'message',
message: `Gemini prompt failed: ${errorMessage}`
});
messageBuffer.addMessage(`Gemini prompt failed: ${errorMessage}`, 'status');
} finally {
session.onThinkingChange(false);
await this.permissionHandler?.cancelAll('Prompt finished');
if (session.queue.size() === 0 && !this.shouldExit) {
sendReady();
}
}
}
}
protected async cleanup(): Promise<void> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
if (this.permissionHandler) {
await this.permissionHandler.cancelAll('Session ended');
this.permissionHandler = null;
}
if (this.backend) {
await this.backend.disconnect();
this.backend = null;
}
if (this.happyServer) {
this.happyServer.stop();
this.happyServer = null;
}
}
private handleAgentMessage(message: AgentMessage): void {
const converted = convertAgentMessage(message);
if (converted) {
this.session.sendAgentMessage(converted);
}
switch (message.type) {
case 'text':
this.messageBuffer.addMessage(message.text, 'assistant');
break;
case 'reasoning':
if (message.live) {
break;
}
this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system');
break;
case 'tool_call':
this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool');
break;
case 'tool_result':
this.messageBuffer.addMessage('Tool result received', 'result');
break;
case 'usage':
break;
case 'plan':
this.messageBuffer.addMessage('Plan updated', 'status');
break;
case 'error':
this.messageBuffer.addMessage(message.message, 'status');
break;
case 'turn_complete':
this.messageBuffer.addMessage('Turn complete', 'status');
break;
default: {
const _exhaustive: never = message;
return _exhaustive;
}
}
}
private applyDisplayMode(permissionMode: PermissionMode | undefined, model?: string): void {
if (permissionMode && permissionMode !== this.displayPermissionMode) {
this.displayPermissionMode = permissionMode;
this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system');
}
if (model && model !== this.displayModel) {
this.displayModel = model;
this.messageBuffer.addMessage(`[MODEL:${model}]`, 'system');
}
}
private async handleAbort(): Promise<void> {
const backend = this.backend;
if (backend && this.session.sessionId) {
await backend.cancelPrompt(this.session.sessionId);
}
await this.permissionHandler?.cancelAll('User aborted');
this.session.sendSessionEvent({ type: 'message', message: 'Session aborted' });
this.session.queue.reset();
this.session.onThinkingChange(false);
this.abortController.abort();
this.abortController = new AbortController();
this.messageBuffer.addMessage('Turn aborted', 'status');
}
private async handleExitFromUi(): Promise<void> {
await this.requestExit('exit', () => this.handleAbort());
}
private async handleSwitchFromUi(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
private async handleSwitchRequest(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
}
function toAcpMcpServers(config: Record<string, { command: string; args: string[] }>): McpServerStdio[] {
return Object.entries(config).map(([name, entry]) => ({
name,
command: entry.command,
args: entry.args,
env: []
}));
}
export async function geminiRemoteLauncher(
session: GeminiSession,
opts: { model?: string; hookSettingsPath?: string }
): Promise<'switch' | 'exit'> {
const launcher = new GeminiRemoteLauncher(session, opts);
return launcher.launch();
}
-69
View File
@@ -1,69 +0,0 @@
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import { runLocalRemoteSession } from '@/agent/loopBase';
import { GeminiSession } from './session';
import { geminiLocalLauncher } from './geminiLocalLauncher';
import { geminiRemoteLauncher } from './geminiRemoteLauncher';
import { ApiClient, ApiSessionClient } from '@/lib';
import type { GeminiMode, PermissionMode } from './types';
interface GeminiLoopOptions {
path: string;
startingMode?: 'local' | 'remote';
startedBy?: 'runner' | 'terminal';
onModeChange: (mode: 'local' | 'remote') => void;
messageQueue: MessageQueue2<GeminiMode>;
session: ApiSessionClient;
api: ApiClient;
permissionMode?: PermissionMode;
model?: string;
hookSettingsPath?: string;
allowedTools?: string[];
resumeSessionId?: string;
onSessionReady?: (session: GeminiSession) => void;
}
export async function geminiLoop(opts: GeminiLoopOptions): Promise<void> {
const logPath = logger.getLogPath();
const startedBy = opts.startedBy ?? 'terminal';
const startingMode = opts.startingMode ?? 'local';
const session = new GeminiSession({
api: opts.api,
client: opts.session,
path: opts.path,
sessionId: opts.resumeSessionId ?? null,
logPath,
messageQueue: opts.messageQueue,
onModeChange: opts.onModeChange,
mode: startingMode,
startedBy,
startingMode,
permissionMode: opts.permissionMode ?? 'default'
});
if (opts.resumeSessionId) {
session.onSessionFound(opts.resumeSessionId);
}
const getCurrentModel = (): string | undefined => {
const sessionModel = session.getModel();
return sessionModel != null ? sessionModel : opts.model;
};
await runLocalRemoteSession({
session,
startingMode: opts.startingMode,
logTag: 'gemini-loop',
runLocal: (instance) => geminiLocalLauncher(instance, {
model: getCurrentModel(),
allowedTools: opts.allowedTools,
hookSettingsPath: opts.hookSettingsPath
}),
runRemote: (instance) => geminiRemoteLauncher(instance, {
model: getCurrentModel(),
hookSettingsPath: opts.hookSettingsPath
}),
onSessionReady: opts.onSessionReady
});
}
-308
View File
@@ -1,308 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockGeminiSession = vi.hoisted(() => ({
setModel: vi.fn(),
setPermissionMode: vi.fn(),
pushKeepAlive: vi.fn(),
thinking: false,
stopKeepAlive: vi.fn()
}));
const harness = vi.hoisted(() => ({
bootstrapArgs: [] as Array<Record<string, unknown>>,
geminiLoopArgs: [] as Array<Record<string, unknown>>,
geminiLoopError: null as Error | null,
session: {
onUserMessage: vi.fn(),
onCancelQueuedMessage: vi.fn(),
rpcHandlerManager: {
registerHandler: vi.fn()
}
}
}));
vi.mock('@/agent/sessionFactory', () => ({
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
harness.bootstrapArgs.push(options);
return {
api: {},
session: harness.session
};
})
}));
vi.mock('./loop', () => ({
geminiLoop: vi.fn(async (options: Record<string, unknown>) => {
harness.geminiLoopArgs.push(options);
if (harness.geminiLoopError) {
throw harness.geminiLoopError;
}
const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined;
if (onSessionReady) {
onSessionReady(mockGeminiSession);
}
})
}));
vi.mock('@/claude/registerKillSessionHandler', () => ({
registerKillSessionHandler: vi.fn()
}));
const lifecycleMock = vi.hoisted(() => ({
registerProcessHandlers: vi.fn(),
cleanupAndExit: vi.fn(async () => {}),
markCrash: vi.fn(),
setExitCode: vi.fn(),
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn(),
hasExplicitSessionEndReason: vi.fn(() => false)
}));
vi.mock('@/agent/runnerLifecycle', () => ({
createModeChangeHandler: vi.fn(() => vi.fn()),
createRunnerLifecycle: vi.fn(() => lifecycleMock),
setControlledByUser: vi.fn()
}));
vi.mock('@/claude/utils/startHookServer', () => ({
startHookServer: vi.fn(async () => ({
port: 1234,
token: 'token',
stop: vi.fn()
}))
}));
vi.mock('@/modules/common/hooks/generateHookSettings', () => ({
cleanupHookSettingsFile: vi.fn(),
generateHookSettingsFile: vi.fn(() => '/tmp/gemini-hooks.json')
}));
const resolveGeminiRuntimeConfigMock = vi.hoisted(() => vi.fn());
vi.mock('./utils/config', () => ({
resolveGeminiRuntimeConfig: resolveGeminiRuntimeConfigMock
}));
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn()
}
}));
vi.mock('@/utils/attachmentFormatter', () => ({
formatMessageWithAttachments: vi.fn((text: string) => text)
}));
import { runGemini } from './runGemini';
describe('runGemini', () => {
beforeEach(() => {
harness.bootstrapArgs.length = 0;
harness.geminiLoopArgs.length = 0;
harness.geminiLoopError = null;
mockGeminiSession.setModel.mockReset();
mockGeminiSession.setPermissionMode.mockReset();
harness.session.onUserMessage.mockReset();
harness.session.rpcHandlerManager.registerHandler.mockReset();
lifecycleMock.registerProcessHandlers.mockClear();
lifecycleMock.cleanupAndExit.mockClear();
lifecycleMock.markCrash.mockClear();
lifecycleMock.setExitCode.mockClear();
lifecycleMock.setArchiveReason.mockClear();
lifecycleMock.setSessionEndReason.mockClear();
resolveGeminiRuntimeConfigMock.mockReset();
});
it('persists a resolved config model before bootstrapping the session', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-pro-preview',
modelSource: 'local'
});
await runGemini({});
expect(harness.bootstrapArgs[0]?.model).toBe('gemini-3-pro-preview');
expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-pro-preview');
});
it('does not persist the hardcoded default fallback model so it floats with machine config', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-flash-preview',
modelSource: 'default'
});
await runGemini({});
expect(harness.bootstrapArgs[0]?.model).toBeUndefined();
expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-3-flash-preview');
});
it('applies model change via set-session-config RPC', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-flash-preview',
modelSource: 'default'
});
await runGemini({});
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
const configHandler = registerCalls.find(
(call: unknown[]) => call[0] === 'set-session-config'
);
expect(configHandler).toBeDefined();
const handler = configHandler![1] as (payload: unknown) => Promise<unknown>;
const result = await handler({ model: 'gemini-2.5-flash' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
expect(applied.model).toBe('gemini-2.5-flash');
});
it('pushes a keepAlive immediately after a config change so the hub UI reflects it', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-flash-preview',
modelSource: 'default'
});
await runGemini({});
// Reset to ignore pushKeepAlive fired from initial onSessionReady setup
mockGeminiSession.pushKeepAlive.mockClear();
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
const configHandler = registerCalls.find(
(call: unknown[]) => call[0] === 'set-session-config'
);
const handler = configHandler![1] as (payload: unknown) => Promise<unknown>;
await handler({ model: 'gemini-2.5-flash' });
expect(mockGeminiSession.pushKeepAlive).toHaveBeenCalledTimes(1);
});
it('rejects invalid model in set-session-config RPC', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-flash-preview',
modelSource: 'default'
});
await runGemini({});
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
const configHandler = registerCalls.find(
(call: unknown[]) => call[0] === 'set-session-config'
);
const handler = configHandler![1] as (payload: unknown) => Promise<unknown>;
await expect(handler({ model: 123 })).rejects.toThrow();
});
it('accepts null model (Auto) in set-session-config RPC', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-flash-preview',
modelSource: 'default'
});
await runGemini({});
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
const configHandler = registerCalls.find(
(call: unknown[]) => call[0] === 'set-session-config'
);
const handler = configHandler![1] as (payload: unknown) => Promise<unknown>;
const result = await handler({ model: null }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
// null (Default) should be passed through to hub for DB clearing
expect(applied.model).toBeNull();
});
it('only includes changed fields in applied response', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-3-flash-preview',
modelSource: 'default'
});
await runGemini({});
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
const configHandler = registerCalls.find(
(call: unknown[]) => call[0] === 'set-session-config'
);
const handler = configHandler![1] as (payload: unknown) => Promise<unknown>;
const result = await handler({ permissionMode: 'default' }) as Record<string, unknown>;
const applied = result.applied as Record<string, unknown>;
expect(applied.permissionMode).toBe('default');
expect(applied).not.toHaveProperty('model');
});
it('stores null model in session on Default selection for keepalive', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-2.5-pro',
modelSource: 'default'
});
await runGemini({});
const registerCalls = harness.session.rpcHandlerManager.registerHandler.mock.calls;
const configHandler = registerCalls.find(
(call: unknown[]) => call[0] === 'set-session-config'
);
const handler = configHandler![1] as (payload: unknown) => Promise<unknown>;
// First set an explicit model
await handler({ model: 'gemini-2.5-flash' });
expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith('gemini-2.5-flash');
// Then select Default (null) — session should store null, not concrete model
await handler({ model: null });
expect(mockGeminiSession.setModel).toHaveBeenLastCalledWith(null);
});
it('passes machine default (not startup model) to geminiLoop for fallback', async () => {
// Session started with explicit model, but machine default differs
resolveGeminiRuntimeConfigMock.mockImplementation((opts?: { model?: string }) => {
if (opts?.model) {
return { model: opts.model, modelSource: 'explicit' };
}
return { model: 'gemini-2.5-pro', modelSource: 'default' };
});
await runGemini({ model: 'gemini-2.5-flash' });
// geminiLoop should receive machine default as fallback, not the explicit startup model
expect(harness.geminiLoopArgs[0]?.model).toBe('gemini-2.5-pro');
});
it('passes resumeSessionId through to geminiLoop', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-2.5-pro',
modelSource: 'default'
});
await runGemini({ resumeSessionId: 'a6157ffa-f692-4b73-82d5-63d42177f4f9' });
expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBe('a6157ffa-f692-4b73-82d5-63d42177f4f9');
});
it('does not set resumeSessionId when not provided', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-2.5-pro',
modelSource: 'default'
});
await runGemini({});
expect(harness.geminiLoopArgs[0]?.resumeSessionId).toBeUndefined();
});
it('preserves crash session end reason instead of overwriting it as completed', async () => {
resolveGeminiRuntimeConfigMock.mockReturnValue({
model: 'gemini-2.5-pro',
modelSource: 'default'
});
harness.geminiLoopError = new Error('loop failed');
await runGemini({});
expect(lifecycleMock.markCrash).toHaveBeenCalledWith(harness.geminiLoopError);
expect(lifecycleMock.setSessionEndReason).not.toHaveBeenCalledWith('completed');
expect(lifecycleMock.cleanupAndExit).toHaveBeenCalled();
});
});
-199
View File
@@ -1,199 +0,0 @@
import { logger } from '@/ui/logger';
import { geminiLoop } from './loop';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { hashObject } from '@/utils/deterministicJson';
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
import type { AgentState } from '@/api/types';
import type { GeminiSession } from './session';
import type { GeminiMode, PermissionMode } from './types';
import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory';
import { registerLocalHandoffHandler } from '@/agent/localHandoff';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { startHookServer } from '@/claude/utils/startHookServer';
import { cleanupHookSettingsFile, generateHookSettingsFile } from '@/modules/common/hooks/generateHookSettings';
import { resolveGeminiRuntimeConfig } from './utils/config';
import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
export async function runGemini(opts: {
startedBy?: 'runner' | 'terminal';
startingMode?: 'local' | 'remote';
permissionMode?: PermissionMode;
model?: string;
resumeSessionId?: string;
existingSessionId?: string;
workingDirectory?: string;
} = {}): Promise<void> {
const workingDirectory = opts.workingDirectory ?? getInvokedCwd();
const startedBy = opts.startedBy ?? 'terminal';
logger.debug(`[gemini] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`);
if (startedBy === 'runner' && opts.startingMode === 'local') {
logger.debug('[gemini] Runner spawn requested with local mode; forcing remote mode');
opts.startingMode = 'remote';
}
const initialState: AgentState = {
controlledByUser: false
};
const machineDefault = resolveGeminiRuntimeConfig().model;
const runtimeConfig = resolveGeminiRuntimeConfig({ model: opts.model });
// Persist only when the user (or env/local config) chose the model. The hardcoded
// default remains undefined in the DB so it floats with the machine config across
// gemini-cli upgrades. Mid-session selections are persisted by the hub via the
// set-session-config RPC, not by this initial bootstrap.
const persistedModel = runtimeConfig.modelSource === 'default'
? undefined
: runtimeConfig.model;
const bootstrap = opts.existingSessionId
? await bootstrapExistingSession({
sessionId: opts.existingSessionId,
flavor: 'gemini',
startedBy,
workingDirectory
})
: await bootstrapSession({
flavor: 'gemini',
startedBy,
workingDirectory,
agentState: initialState,
model: persistedModel
});
const { api, session } = bootstrap;
const startingMode: 'local' | 'remote' = opts.startingMode
?? (startedBy === 'runner' ? 'remote' : 'local');
setControlledByUser(session, startingMode);
const messageQueue = new MessageQueue2<GeminiMode>((mode) => hashObject({
permissionMode: mode.permissionMode,
model: mode.model
}));
const sessionWrapperRef: { current: GeminiSession | null } = { current: null };
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
let sessionModel: string | null = persistedModel ?? null;
let resolvedModel = sessionModel ?? machineDefault;
const hookServer = await startHookServer({
onSessionHook: (sessionId, data) => {
logger.debug(`[gemini] Session hook received: ${sessionId}`);
const currentSession = sessionWrapperRef.current;
if (!currentSession) {
return;
}
if (currentSession.sessionId !== sessionId) {
currentSession.onSessionFound(sessionId);
}
if (typeof data.transcript_path === 'string') {
currentSession.onTranscriptPathFound(data.transcript_path);
}
}
});
const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token, {
filenamePrefix: 'gemini-session-hook',
logLabel: 'gemini-hook-settings',
hooksEnabled: true
});
const lifecycle = createRunnerLifecycle({
session,
logTag: 'gemini',
stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive(),
onAfterClose: () => {
hookServer.stop();
cleanupHookSettingsFile(hookSettingsPath, 'gemini-hook-settings');
}
});
lifecycle.registerProcessHandlers();
registerKillSessionHandler(session.rpcHandlerManager, lifecycle);
registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle);
const syncSessionMode = () => {
const sessionInstance = sessionWrapperRef.current;
if (!sessionInstance) {
return;
}
sessionInstance.setPermissionMode(currentPermissionMode);
sessionInstance.setModel(sessionModel);
// Notify hub immediately to reflect changes in UI
sessionInstance.pushKeepAlive();
logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`);
};
session.onUserMessage((message, localId) => {
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
const mode: GeminiMode = {
permissionMode: currentPermissionMode,
model: resolvedModel
};
messageQueue.push(formattedText, mode, localId);
});
session.onCancelQueuedMessage((localId) => {
const removed = messageQueue.cancelByLocalId(localId);
logger.debug(`[gemini] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`);
return removed;
});
registerSessionConfigRpc<PermissionMode>({
rpcHandlerManager: session.rpcHandlerManager,
flavor: 'gemini',
modelMode: 'nullable',
onApply: (config) => {
if (config.permissionMode !== undefined) {
currentPermissionMode = config.permissionMode;
}
if (config.model !== undefined) {
sessionModel = config.model;
resolvedModel = sessionModel ?? machineDefault;
}
},
onAfterApply: syncSessionMode
});
let crashed = false;
try {
await geminiLoop({
path: workingDirectory,
startingMode,
startedBy,
messageQueue,
session,
api,
permissionMode: currentPermissionMode,
model: machineDefault,
hookSettingsPath,
resumeSessionId: opts.resumeSessionId,
onModeChange: createModeChangeHandler(session),
onSessionReady: (instance) => {
sessionWrapperRef.current = instance;
syncSessionMode();
}
});
} catch (error) {
crashed = true;
lifecycle.markCrash(error);
logger.debug('[gemini] Loop error:', error);
} finally {
const localFailure = sessionWrapperRef.current?.localLaunchFailure;
if (localFailure?.exitReason === 'exit') {
lifecycle.setExitCode(1);
lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`);
lifecycle.setSessionEndReason('error');
} else if (!crashed) {
lifecycle.setSessionEndReason('completed');
}
await lifecycle.cleanupAndExit();
}
}
-100
View File
@@ -1,100 +0,0 @@
import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { AgentSessionBase } from '@/agent/sessionBase';
import type { GeminiMode, PermissionMode } from './types';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
type LocalLaunchFailure = {
message: string;
exitReason: LocalLaunchExitReason;
};
export class GeminiSession extends AgentSessionBase<GeminiMode> {
transcriptPath: string | null = null;
readonly startedBy: 'runner' | 'terminal';
readonly startingMode: 'local' | 'remote';
localLaunchFailure: LocalLaunchFailure | null = null;
private transcriptPathCallbacks: Array<(path: string) => void> = [];
constructor(opts: {
api: ApiClient;
client: ApiSessionClient;
path: string;
logPath: string;
sessionId: string | null;
messageQueue: MessageQueue2<GeminiMode>;
onModeChange: (mode: 'local' | 'remote') => void;
mode?: 'local' | 'remote';
startedBy: 'runner' | 'terminal';
startingMode: 'local' | 'remote';
permissionMode?: PermissionMode;
}) {
super({
api: opts.api,
client: opts.client,
path: opts.path,
logPath: opts.logPath,
sessionId: opts.sessionId,
messageQueue: opts.messageQueue,
onModeChange: opts.onModeChange,
mode: opts.mode,
sessionLabel: 'GeminiSession',
sessionIdLabel: 'Gemini',
applySessionIdToMetadata: (metadata, sessionId) => ({
...metadata,
geminiSessionId: sessionId
}),
permissionMode: opts.permissionMode
});
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode;
}
onTranscriptPathFound(path: string): void {
if (this.transcriptPath === path) {
return;
}
this.transcriptPath = path;
for (const callback of this.transcriptPathCallbacks) {
callback(path);
}
}
addTranscriptPathCallback(cb: (path: string) => void): void {
this.transcriptPathCallbacks.push(cb);
}
removeTranscriptPathCallback(cb: (path: string) => void): void {
const index = this.transcriptPathCallbacks.indexOf(cb);
if (index !== -1) {
this.transcriptPathCallbacks.splice(index, 1);
}
}
setPermissionMode = (mode: PermissionMode): void => {
this.permissionMode = mode;
};
setModel = (model: string | null): void => {
this.model = model;
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
sendAgentMessage = (message: unknown): void => {
this.client.sendAgentMessage(message);
};
sendUserMessage = (text: string): void => {
this.client.sendUserMessage(text);
};
sendSessionEvent = (event: Parameters<ApiSessionClient['sendSessionEvent']>[0]): void => {
this.client.sendSessionEvent(event);
};
}
-8
View File
@@ -1,8 +0,0 @@
import type { GeminiPermissionMode } from '@hapi/protocol/types';
export type PermissionMode = GeminiPermissionMode;
export interface GeminiMode {
permissionMode: PermissionMode;
model?: string;
}
-143
View File
@@ -1,143 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { logger } from '@/ui/logger';
import { DEFAULT_GEMINI_MODEL } from '@hapi/protocol';
export const GEMINI_API_KEY_ENV = 'GEMINI_API_KEY';
export const GOOGLE_API_KEY_ENV = 'GOOGLE_API_KEY';
export const GEMINI_MODEL_ENV = 'GEMINI_MODEL';
export { DEFAULT_GEMINI_MODEL };
export type GeminiLocalConfig = {
token?: string;
model?: string;
};
export type GeminiModelSource = 'explicit' | 'env' | 'local' | 'default';
const GEMINI_DIR = join(homedir(), '.gemini');
const SETTINGS_PATH = join(GEMINI_DIR, 'settings.json');
const CONFIG_PATH = join(GEMINI_DIR, 'config.json');
const OAUTH_PATH = join(GEMINI_DIR, 'oauth_creds.json');
function readJsonFile(path: string): Record<string, unknown> | null {
if (!existsSync(path)) {
return null;
}
try {
const raw = readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch (error) {
logger.debug(`[gemini-config] Failed to read ${path}: ${error}`);
}
return null;
}
function extractModel(settings: Record<string, unknown>): string | undefined {
const modelEntry = settings.model;
if (modelEntry && typeof modelEntry === 'object') {
const name = (modelEntry as Record<string, unknown>).name;
if (typeof name === 'string' && name.trim().length > 0) {
return name.trim();
}
}
const model = settings.model;
if (typeof model === 'string' && model.trim().length > 0) {
return model.trim();
}
return undefined;
}
function extractToken(settings: Record<string, unknown>): string | undefined {
const tokenKeys = ['access_token', 'token', 'apiKey', GEMINI_API_KEY_ENV, GOOGLE_API_KEY_ENV];
for (const key of tokenKeys) {
const value = settings[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
export function readGeminiLocalConfig(): GeminiLocalConfig {
const settingsFile = readJsonFile(SETTINGS_PATH);
const configFile = readJsonFile(CONFIG_PATH);
const oauthFile = readJsonFile(OAUTH_PATH);
const model = settingsFile ? extractModel(settingsFile) : undefined;
const token = oauthFile
? extractToken(oauthFile)
: configFile
? extractToken(configFile)
: undefined;
return {
model,
token
};
}
export function resolveGeminiRuntimeConfig(opts: {
model?: string;
token?: string;
} = {}): { model: string; token?: string; modelSource: GeminiModelSource } {
const local = readGeminiLocalConfig();
let modelSource: GeminiModelSource = 'default';
let model: string = DEFAULT_GEMINI_MODEL;
if (opts.model) {
model = opts.model;
modelSource = 'explicit';
} else if (process.env[GEMINI_MODEL_ENV]) {
model = process.env[GEMINI_MODEL_ENV]!;
modelSource = 'env';
} else if (local.model) {
model = local.model;
modelSource = 'local';
}
const token = opts.token
?? process.env[GEMINI_API_KEY_ENV]
?? process.env[GOOGLE_API_KEY_ENV]
?? local.token;
return { model, token, modelSource };
}
export function buildGeminiEnv(opts: {
model?: string;
token?: string;
hookSettingsPath?: string;
cwd?: string;
}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env
};
if (opts.model) {
env[GEMINI_MODEL_ENV] = opts.model;
}
if (opts.token && !env[GEMINI_API_KEY_ENV] && !env[GOOGLE_API_KEY_ENV]) {
env[GEMINI_API_KEY_ENV] = opts.token;
}
if (opts.hookSettingsPath) {
env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = opts.hookSettingsPath;
}
if (opts.cwd) {
env.GEMINI_PROJECT_DIR = opts.cwd;
}
return env;
}
-50
View File
@@ -1,50 +0,0 @@
import { AcpSdkBackend } from '@/agent/backends/acp';
import { buildGeminiEnv, resolveGeminiRuntimeConfig } from './config';
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function createGeminiBackend(opts: {
model?: string;
token?: string;
resumeSessionId?: string | null;
hookSettingsPath?: string;
cwd?: string;
permissionMode?: string;
}): AcpSdkBackend {
const { model, token } = resolveGeminiRuntimeConfig({
model: opts.model,
token: opts.token
});
const args = ['--experimental-acp'];
if (opts.resumeSessionId) {
args.push('--resume', opts.resumeSessionId);
}
if (model) {
args.push('--model', model);
}
if (opts.permissionMode === 'yolo' || opts.permissionMode === 'safe-yolo') {
args.push('--yolo');
}
const env = buildGeminiEnv({
model,
token,
hookSettingsPath: opts.hookSettingsPath,
cwd: opts.cwd
});
return new AcpSdkBackend({
command: 'gemini',
args,
env: filterEnv(env)
});
}
-170
View File
@@ -1,170 +0,0 @@
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
import type { GeminiPermissionMode } from '@hapi/protocol/types';
import { deriveToolName } from '@/agent/utils';
import { logger } from '@/ui/logger';
import {
BasePermissionHandler,
type AutoApprovalDecision,
type PendingPermissionRequest,
type PermissionCompletion
} from '@/modules/common/permission/BasePermissionHandler';
interface PermissionResponseMessage {
id: string;
approved: boolean;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
reason?: string;
}
function deriveToolInput(request: PermissionRequest): unknown {
if (request.rawInput !== undefined) {
return request.rawInput;
}
return request.rawOutput;
}
function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) {
return match.optionId;
}
}
return request.options.length > 0 ? request.options[0].optionId : null;
}
function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse {
if (decision === 'abort') {
return { outcome: 'cancelled' };
}
if (decision === 'approved_for_session') {
const optionId = pickOptionId(request, ['allow_always', 'allow_once']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
if (decision === 'approved') {
const optionId = pickOptionId(request, ['allow_once', 'allow_always']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
const optionId = pickOptionId(request, ['reject_once', 'reject_always']);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
export class GeminiPermissionHandler extends BasePermissionHandler<PermissionResponseMessage, void> {
private readonly pendingBackendRequests = new Map<string, PermissionRequest>();
constructor(
session: ApiSessionClient,
private readonly backend: AgentBackend,
private readonly getPermissionMode: () => GeminiPermissionMode | undefined
) {
super(session);
this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request));
}
private handlePermissionRequest(request: PermissionRequest): void {
const toolName = deriveToolName({
title: request.title,
kind: request.kind,
rawInput: request.rawInput
});
const toolInput = deriveToolInput(request);
const mode = this.getPermissionMode() ?? 'default';
const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId);
if (autoDecision) {
void this.autoApprove(request, toolName, toolInput, autoDecision);
return;
}
this.pendingBackendRequests.set(request.id, request);
this.addPendingRequest(request.id, toolName, toolInput, {
resolve: () => {},
reject: () => {}
});
logger.debug(`[Gemini] Permission request queued for ${toolName} (${request.id})`);
}
private async autoApprove(
request: PermissionRequest,
toolName: string,
toolInput: unknown,
decision: AutoApprovalDecision
): Promise<void> {
const outcome = mapDecisionToOutcome(request, decision);
await this.backend.respondToPermission(request.sessionId, request, outcome);
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: toolInput,
createdAt: Date.now(),
completedAt: Date.now(),
status: 'approved',
decision
}
}
}));
logger.debug(`[Gemini] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
}
protected async handlePermissionResponse(
response: PermissionResponseMessage,
pending: PendingPermissionRequest<void>
): Promise<PermissionCompletion> {
const pendingRequest = this.pendingBackendRequests.get(response.id);
if (pendingRequest) {
this.pendingBackendRequests.delete(response.id);
} else {
logger.debug('[Gemini] Permission response missing backend request', response.id);
}
const decision = response.decision ?? (response.approved ? 'approved' : 'denied');
if (decision === 'abort' && pendingRequest) {
await this.backend.cancelPrompt(pendingRequest.sessionId);
}
if (pendingRequest) {
const outcome = mapDecisionToOutcome(pendingRequest, decision);
await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome);
}
pending.resolve();
logger.debug(`[Gemini] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`);
return {
status: response.approved ? 'approved' : 'denied',
decision,
reason: response.reason
};
}
protected handleMissingPendingResponse(response: PermissionResponseMessage): void {
logger.debug('[Gemini] Permission response received for unknown request', response.id);
}
async cancelAll(reason: string): Promise<void> {
const pending = Array.from(this.pendingBackendRequests.values());
this.pendingBackendRequests.clear();
for (const request of pending) {
await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' });
}
this.cancelPendingRequests({
completedReason: reason,
rejectMessage: reason,
decision: 'abort'
});
}
}
-176
View File
@@ -1,176 +0,0 @@
import { readFile } from 'node:fs/promises';
import { logger } from '@/ui/logger';
import {
BaseSessionScanner,
SessionFileScanEntry,
SessionFileScanResult,
SessionFileScanStats
} from '@/modules/common/session/BaseSessionScanner';
type GeminiTranscriptMessage = {
id?: string;
type?: string;
content?: string;
[key: string]: unknown;
};
type GeminiTranscript = {
sessionId?: string;
messages?: GeminiTranscriptMessage[];
[key: string]: unknown;
};
export async function createGeminiSessionScanner(opts: {
transcriptPath: string | null;
onMessage: (message: GeminiTranscriptMessage) => void;
onSessionId?: (sessionId: string) => void;
}) {
const scanner = new GeminiSessionScanner({
transcriptPath: opts.transcriptPath,
onMessage: opts.onMessage,
onSessionId: opts.onSessionId
});
await scanner.start();
return {
cleanup: async () => {
await scanner.cleanup();
},
onNewSession: (transcriptPath: string) => {
void scanner.setTranscriptPath(transcriptPath);
}
};
}
class GeminiSessionScanner extends BaseSessionScanner<GeminiTranscriptMessage> {
private transcriptPath: string | null;
private readonly onMessage: (message: GeminiTranscriptMessage) => void;
private readonly onSessionId?: (sessionId: string) => void;
private observedSessionId: string | null = null;
constructor(opts: {
transcriptPath: string | null;
onMessage: (message: GeminiTranscriptMessage) => void;
onSessionId?: (sessionId: string) => void;
}) {
super({ intervalMs: 2000 });
this.transcriptPath = opts.transcriptPath;
this.onMessage = opts.onMessage;
this.onSessionId = opts.onSessionId;
}
async setTranscriptPath(path: string): Promise<void> {
if (this.transcriptPath === path) {
return;
}
this.transcriptPath = path;
await this.primeTranscript(path);
this.invalidate();
}
protected async initialize(): Promise<void> {
if (this.transcriptPath) {
await this.primeTranscript(this.transcriptPath);
}
}
protected async findSessionFiles(): Promise<string[]> {
if (!this.transcriptPath) {
return [];
}
return [this.transcriptPath];
}
protected shouldWatchFile(filePath: string): boolean {
return Boolean(this.transcriptPath && filePath === this.transcriptPath);
}
protected async parseSessionFile(filePath: string, cursor: number): Promise<SessionFileScanResult<GeminiTranscriptMessage>> {
const transcript = await readTranscript(filePath);
if (!transcript) {
return { events: [], nextCursor: cursor };
}
this.updateSessionId(transcript.sessionId);
const messages = transcript.messages ?? [];
let startIndex = cursor;
if (startIndex > messages.length) {
startIndex = 0;
}
const events: SessionFileScanEntry<GeminiTranscriptMessage>[] = [];
for (let index = startIndex; index < messages.length; index += 1) {
events.push({ event: messages[index], lineIndex: index });
}
return {
events,
nextCursor: messages.length
};
}
protected generateEventKey(event: GeminiTranscriptMessage, context: { filePath: string; lineIndex?: number }): string {
if (event.id && event.id.length > 0) {
return `${context.filePath}:${event.id}`;
}
return `${context.filePath}:${context.lineIndex ?? -1}`;
}
protected async handleFileScan(stats: SessionFileScanStats<GeminiTranscriptMessage>): Promise<void> {
for (const message of stats.events) {
this.onMessage(message);
}
if (stats.newCount > 0) {
logger.debug(`[gemini-session-scanner] ${stats.newCount} new messages from ${stats.filePath}`);
}
this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []);
}
private updateSessionId(sessionId: string | undefined): void {
if (!sessionId || sessionId.length === 0) {
return;
}
if (this.observedSessionId === sessionId) {
return;
}
this.observedSessionId = sessionId;
this.onSessionId?.(sessionId);
}
private async primeTranscript(filePath: string): Promise<void> {
const transcript = await readTranscript(filePath);
if (!transcript) {
return;
}
this.updateSessionId(transcript.sessionId);
const messages = transcript.messages ?? [];
const keys = messages.map((message, index) => this.generateEventKey(message, { filePath, lineIndex: index }));
this.seedProcessedKeys(keys);
this.setCursor(filePath, messages.length);
}
}
async function readTranscript(filePath: string): Promise<GeminiTranscript | null> {
try {
const raw = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
const record = parsed as Record<string, unknown>;
const messages = Array.isArray(record.messages)
? record.messages.filter((value): value is GeminiTranscriptMessage => Boolean(value && typeof value === 'object'))
: [];
const sessionId = typeof record.sessionId === 'string' ? record.sessionId : undefined;
return {
sessionId,
messages
};
} catch (error) {
logger.debug(`[gemini-session-scanner] Failed to read transcript ${filePath}: ${error}`);
return null;
}
}
+5 -1
View File
@@ -31,7 +31,7 @@ describe('buildCliArgs', () => {
})
it('prefers --permission-mode over --yolo when both present', () => {
const args = buildCliArgs('gemini', {
const args = buildCliArgs('cursor', {
directory: '/tmp',
permissionMode: 'yolo',
}, true)
@@ -43,6 +43,10 @@ describe('buildCliArgs', () => {
expect(yoloIdx).toBe(-1)
})
it('throws for the removed gemini agent (no longer launchable)', () => {
expect(() => buildCliArgs('gemini', { directory: '/tmp' })).toThrow(/no longer supported/)
})
it('adds --yolo when no permissionMode and yolo is true', () => {
const args = buildCliArgs('claude', {
directory: '/tmp',
+13 -9
View File
@@ -287,6 +287,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}):
const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options;
const agent = options.agent ?? 'claude';
if (agent === 'gemini') {
throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18). Existing Gemini sessions remain viewable in the web UI.');
}
const yolo = options.yolo === true;
const sessionType = options.sessionType ?? 'simple';
const worktreeName = options.worktreeName;
@@ -1073,19 +1076,20 @@ export function buildCliArgs(
options: SpawnSessionOptions,
yolo?: boolean
): string[] {
if (agent === 'gemini') {
throw new Error('Gemini CLI is no longer supported and cannot be launched (Google sunset the consumer Gemini CLI on 2026-06-18).');
}
const agentCommand = agent === 'codex'
? 'codex'
: agent === 'cursor'
? 'cursor'
: agent === 'gemini'
? 'gemini'
: agent === 'kimi'
? 'kimi'
: agent === 'opencode'
? 'opencode'
: agent === 'pi'
? 'pi'
: 'claude';
: agent === 'kimi'
? 'kimi'
: agent === 'opencode'
? 'opencode'
: agent === 'pi'
? 'pi'
: 'claude';
const args = [agentCommand];
if (options.resumeSessionId) {
if (agent === 'codex') {
-187
View File
@@ -1,187 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useStdout } from 'ink';
import { MessageBuffer, type BufferedMessage } from './messageBuffer';
import { useSwitchControls } from './useSwitchControls';
interface GeminiDisplayProps {
messageBuffer: MessageBuffer;
logPath?: string;
onExit?: () => void;
onSwitchToLocal?: () => void;
}
function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string | null {
const prefix = `[${tag}:`;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.type !== 'system') {
continue;
}
if (!message.content.startsWith(prefix)) {
continue;
}
const match = message.content.match(/\[\w+:(.+?)\]/);
if (match && match[1]) {
return match[1];
}
}
return null;
}
export const GeminiDisplay: React.FC<GeminiDisplayProps> = ({
messageBuffer,
logPath,
onExit,
onSwitchToLocal
}) => {
const [messages, setMessages] = useState<BufferedMessage[]>([]);
const [model, setModel] = useState<string | null>(null);
const [permissionMode, setPermissionMode] = useState<string | null>(null);
const { confirmationMode, actionInProgress } = useSwitchControls({
onExit,
onSwitch: onSwitchToLocal
});
const { stdout } = useStdout();
const terminalWidth = stdout.columns || 80;
const terminalHeight = stdout.rows || 24;
useEffect(() => {
setMessages(messageBuffer.getMessages());
const unsubscribe = messageBuffer.onUpdate((newMessages) => {
setMessages(newMessages);
const nextModel = extractTag(newMessages, 'MODEL');
if (nextModel) {
setModel(nextModel);
}
const nextMode = extractTag(newMessages, 'MODE');
if (nextMode) {
setPermissionMode(nextMode);
}
});
return () => {
unsubscribe();
};
}, [messageBuffer]);
const getMessageColor = (type: BufferedMessage['type']): string => {
switch (type) {
case 'user': return 'magenta';
case 'assistant': return 'cyan';
case 'system': return 'blue';
case 'tool': return 'yellow';
case 'result': return 'green';
case 'status': return 'gray';
default: return 'white';
}
};
const formatMessage = (msg: BufferedMessage): string => {
const lines = msg.content.split('\n');
const maxLineLength = Math.max(1, terminalWidth - 10);
return lines.map(line => {
if (line.length <= maxLineLength) return line;
const chunks: string[] = [];
for (let i = 0; i < line.length; i += maxLineLength) {
chunks.push(line.slice(i, i + maxLineLength));
}
return chunks.join('\n');
}).join('\n');
};
const visibleMessages = messages.filter((msg) => {
if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) {
return false;
}
if (msg.type === 'system' && msg.content.startsWith('[MODE:')) {
return false;
}
return true;
});
return (
<Box flexDirection="column" width={terminalWidth} height={terminalHeight}>
<Box
flexDirection="column"
width={terminalWidth}
height={terminalHeight - 4}
borderStyle="round"
borderColor="gray"
paddingX={1}
overflow="hidden"
>
<Box flexDirection="column" marginBottom={1}>
<Text color="gray" bold>Gemini Agent Messages</Text>
<Text color="gray" dimColor>{'-'.repeat(Math.min(terminalWidth - 4, 60))}</Text>
</Box>
<Box flexDirection="column" height={terminalHeight - 10} overflow="hidden">
{visibleMessages.length === 0 ? (
<Text color="gray" dimColor>Waiting for messages...</Text>
) : (
visibleMessages
.slice(-Math.max(1, terminalHeight - 10))
.map((msg) => (
<Box key={msg.id} flexDirection="column" marginBottom={1}>
<Text color={getMessageColor(msg.type)} dimColor>
{formatMessage(msg)}
</Text>
</Box>
))
)}
</Box>
</Box>
<Box
width={terminalWidth}
borderStyle="round"
borderColor={
actionInProgress ? 'gray' :
confirmationMode === 'exit' ? 'red' :
confirmationMode === 'switch' ? 'yellow' :
'green'
}
paddingX={2}
justifyContent="center"
alignItems="center"
flexDirection="column"
>
<Box flexDirection="column" alignItems="center">
{actionInProgress === 'exiting' ? (
<Text color="gray" bold>
Exiting agent...
</Text>
) : actionInProgress === 'switching' ? (
<Text color="gray" bold>
Switching to local mode...
</Text>
) : confirmationMode === 'exit' ? (
<Text color="red" bold>
Press Ctrl-C again to exit the agent
</Text>
) : confirmationMode === 'switch' ? (
<Text color="yellow" bold>
Press space again to switch to local mode
</Text>
) : (
<Text color="green" bold>
Gemini running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'}
</Text>
)}
{(model || permissionMode) && (
<Text color="gray" dimColor>
{model ? `Model: ${model}` : 'Model: default'}
{permissionMode ? ` | Permission: ${permissionMode}` : ''}
</Text>
)}
{process.env.DEBUG && logPath && (
<Text color="gray" dimColor>
Debug logs: {logPath}
</Text>
)}
</Box>
</Box>
</Box>
);
};
+21
View File
@@ -1,5 +1,8 @@
import { describe, expect, it, test } from 'bun:test'
import {
AGENT_FLAVORS,
AgentFlavorSchema,
CREATABLE_AGENT_FLAVORS,
getPermissionModeLabel,
getPermissionModeOptionsForFlavor,
getPermissionModeTone,
@@ -7,6 +10,24 @@ import {
isPermissionModeAllowedForFlavor,
} from './modes'
describe('Gemini CLI sunset (read-only, not creatable)', () => {
test('gemini stays a valid flavor so existing stored sessions still validate/load', () => {
expect(AGENT_FLAVORS).toContain('gemini')
expect(AgentFlavorSchema.safeParse('gemini').success).toBe(true)
})
test('gemini is excluded from creatable flavors (not offered for new sessions)', () => {
expect(CREATABLE_AGENT_FLAVORS).not.toContain('gemini')
})
test('all other flavors remain creatable', () => {
for (const flavor of AGENT_FLAVORS) {
if (flavor === 'gemini') continue
expect(CREATABLE_AGENT_FLAVORS).toContain(flavor)
}
})
})
describe('getPermissionModesForFlavor', () => {
test("returns [] for flavor 'pi' (RPC mode has no runtime permission switching)", () => {
expect(getPermissionModesForFlavor('pi')).toEqual([])
+8
View File
@@ -11,6 +11,14 @@ export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'op
export type AgentFlavor = typeof AGENT_FLAVORS[number]
export const AgentFlavorSchema = z.enum(AGENT_FLAVORS)
// Flavors offered when CREATING a new session. Gemini CLI is intentionally
// excluded: Google sunset the consumer Gemini CLI (2026-06-18) so it can no
// longer be launched. It is kept in AGENT_FLAVORS / AgentFlavorSchema above so
// existing stored Gemini sessions still validate and remain viewable.
export const CREATABLE_AGENT_FLAVORS: readonly AgentFlavor[] = AGENT_FLAVORS.filter(
(flavor) => flavor !== 'gemini'
)
export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan'] as const
export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number]
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
import { AgentSelector } from './AgentSelector'
import type { AgentType } from './types'
function renderedAgentValues(): string[] {
const { container } = render(
<AgentSelector agent={'claude' as AgentType} isDisabled={false} onAgentChange={() => {}} />
)
return Array.from(container.querySelectorAll('input[type="radio"]'))
.map((el) => (el as HTMLInputElement).value)
}
describe('AgentSelector', () => {
it('does not offer the sunset Gemini CLI as a new-session agent', () => {
expect(renderedAgentValues()).not.toContain('gemini')
})
it('offers exactly the creatable agent flavors', () => {
expect(renderedAgentValues()).toEqual([...CREATABLE_AGENT_FLAVORS])
})
})
@@ -1,4 +1,4 @@
import { AGENT_FLAVORS } from '@hapi/protocol'
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
import type { AgentType } from './types'
import { useTranslation } from '@/lib/use-translation'
@@ -15,7 +15,7 @@ export function AgentSelector(props: {
{t('newSession.agent')}
</label>
<div className="flex flex-wrap gap-x-3 gap-y-2">
{AGENT_FLAVORS.map((agentType) => (
{CREATABLE_AGENT_FLAVORS.map((agentType) => (
<label
key={agentType}
className="flex items-center gap-1.5 cursor-pointer"
@@ -80,4 +80,29 @@ describe('newSessionFormDraft', () => {
const draft = loadNewSessionFormDraft()!
expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false)
})
it('coerces a stale uncreatable agent (gemini) to claude and resets dependent fields', () => {
saveNewSessionFormDraft({
agent: 'gemini',
model: 'gemini-2.5-pro',
cursorSelectedBase: 'composer-2.5',
machineId: 'machine-1',
effort: 'high',
modelReasoningEffort: 'high',
yoloMode: true,
sessionType: 'simple',
worktreeName: ''
})
const loaded = loadNewSessionFormDraft()!
expect(loaded.agent).toBe('claude')
// agent-dependent fields reset so a Gemini model isn't carried into Claude
expect(loaded.model).toBe('auto')
expect(loaded.cursorSelectedBase).toBe('auto')
expect(loaded.effort).toBe('auto')
expect(loaded.modelReasoningEffort).toBe('default')
// agent-independent fields preserved
expect(loaded.yoloMode).toBe(true)
expect(loaded.machineId).toBe('machine-1')
})
})
@@ -1,3 +1,4 @@
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types'
const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft'
@@ -32,13 +33,25 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null {
if (typeof parsed.agent !== 'string' || typeof parsed.model !== 'string') {
return null
}
// Coerce a stale/uncreatable agent (e.g. a pre-removal 'gemini' draft)
// back to a launchable default. When the agent is coerced, also drop the
// agent-dependent fields (model / cursor base / effort) so a Gemini
// draft does not carry a Gemini model into the Claude fallback.
const restoredAgent: AgentType = (CREATABLE_AGENT_FLAVORS as readonly string[]).includes(parsed.agent)
? (parsed.agent as AgentType)
: 'claude'
const agentPreserved = restoredAgent === parsed.agent
return {
agent: parsed.agent as AgentType,
model: parsed.model,
cursorSelectedBase: typeof parsed.cursorSelectedBase === 'string' ? parsed.cursorSelectedBase : 'auto',
agent: restoredAgent,
model: agentPreserved ? parsed.model : 'auto',
cursorSelectedBase: agentPreserved && typeof parsed.cursorSelectedBase === 'string'
? parsed.cursorSelectedBase
: 'auto',
machineId: typeof parsed.machineId === 'string' ? parsed.machineId : null,
effort: (parsed.effort as ClaudeEffort | undefined) ?? 'auto',
modelReasoningEffort: (parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default',
effort: agentPreserved ? ((parsed.effort as ClaudeEffort | undefined) ?? 'auto') : 'auto',
modelReasoningEffort: agentPreserved
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
: 'default',
yoloMode: Boolean(parsed.yoloMode),
sessionType: (parsed.sessionType as SessionType | undefined) ?? 'simple',
worktreeName: typeof parsed.worktreeName === 'string' ? parsed.worktreeName : ''
+4 -2
View File
@@ -1,10 +1,12 @@
import { AGENT_FLAVORS } from '@hapi/protocol'
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
import type { AgentType } from './types'
const AGENT_STORAGE_KEY = 'hapi:newSession:agent'
const YOLO_STORAGE_KEY = 'hapi:newSession:yolo'
const VALID_AGENTS = AGENT_FLAVORS
// Only launchable flavors are valid defaults; a stale 'gemini' preference
// (no longer creatable) falls back to 'claude'.
const VALID_AGENTS = CREATABLE_AGENT_FLAVORS
export function loadPreferredAgent(): AgentType {
try {