mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
* 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>
227 lines
8.6 KiB
TypeScript
227 lines
8.6 KiB
TypeScript
import chalk from 'chalk'
|
|
import React from 'react'
|
|
import { render } from 'ink'
|
|
import { existsSync } from 'node:fs'
|
|
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
|
|
import type {
|
|
ClaudePermissionMode,
|
|
CodexPermissionMode,
|
|
CursorPermissionMode,
|
|
KimiPermissionMode,
|
|
OpencodePermissionMode
|
|
} from '@hapi/protocol/types'
|
|
import { ApiClient } from '@/api/api'
|
|
import type { ReasoningEffort } from '@/codex/appServerTypes'
|
|
import { authAndSetupMachineIfNeeded } from '@/ui/auth'
|
|
import { initializeToken } from '@/ui/tokenInit'
|
|
import { maybeAutoStartServer } from '@/utils/autoStartServer'
|
|
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
|
|
import { ResumeSessionPicker } from '@/ui/ink/ResumeSessionPicker'
|
|
import type { CommandDefinition } from './types'
|
|
|
|
function formatSessionLine(session: ResumableSession, index: number): string {
|
|
const name = session.name ?? session.summary ?? session.sessionId
|
|
const state = session.active
|
|
? session.controlledByUser ? 'local' : 'remote'
|
|
: 'inactive'
|
|
return `${index + 1}. ${session.flavor.padEnd(8)} ${state.padEnd(8)} ${name} ${session.directory}`
|
|
}
|
|
|
|
async function selectSession(sessions: ResumableSession[]): Promise<string> {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
let settled = false
|
|
const complete = (callback: () => void) => {
|
|
if (settled) return
|
|
settled = true
|
|
instance.unmount()
|
|
callback()
|
|
}
|
|
const instance = render(React.createElement(ResumeSessionPicker, {
|
|
sessions,
|
|
onSelect: (sessionId: string) => complete(() => resolve(sessionId)),
|
|
onCancel: () => complete(() => reject(new Error('Selection cancelled')))
|
|
}), {
|
|
patchConsole: false,
|
|
exitOnCtrlC: false
|
|
})
|
|
})
|
|
}
|
|
|
|
function assertTargetMachine(target: LocalResumeTarget, machineId: string): void {
|
|
if (!target.machineId) {
|
|
throw new Error('Session metadata missing machine id')
|
|
}
|
|
if (target.machineId !== machineId) {
|
|
throw new Error(`Session belongs to another machine (${target.machineId})`)
|
|
}
|
|
}
|
|
|
|
function assertDirectoryExists(target: LocalResumeTarget): void {
|
|
if (!existsSync(target.directory)) {
|
|
throw new Error(`Session directory does not exist: ${target.directory}`)
|
|
}
|
|
}
|
|
|
|
async function dispatchLocalResume(target: LocalResumeTarget): Promise<void> {
|
|
const base = {
|
|
existingSessionId: target.sessionId,
|
|
workingDirectory: target.directory,
|
|
resumeSessionId: target.agentSessionId,
|
|
startedBy: 'terminal' as const,
|
|
permissionMode: target.permissionMode
|
|
}
|
|
|
|
if (target.flavor === 'claude') {
|
|
const { runClaude } = await import('@/claude/runClaude')
|
|
await runClaude({
|
|
existingSessionId: base.existingSessionId,
|
|
workingDirectory: base.workingDirectory,
|
|
resumeSessionId: base.resumeSessionId,
|
|
startedBy: base.startedBy,
|
|
permissionMode: base.permissionMode as ClaudePermissionMode | undefined,
|
|
startingMode: 'local',
|
|
model: target.model ?? undefined,
|
|
effort: target.effort ?? undefined
|
|
})
|
|
return
|
|
}
|
|
|
|
if (target.flavor === 'codex') {
|
|
assertCodexLocalSupported()
|
|
const { runCodex } = await import('@/codex/runCodex')
|
|
await runCodex({
|
|
existingSessionId: base.existingSessionId,
|
|
workingDirectory: base.workingDirectory,
|
|
resumeSessionId: base.resumeSessionId,
|
|
startedBy: base.startedBy,
|
|
permissionMode: base.permissionMode as CodexPermissionMode | undefined,
|
|
model: target.model ?? undefined,
|
|
modelReasoningEffort: (target.modelReasoningEffort ?? undefined) as ReasoningEffort | undefined,
|
|
collaborationMode: target.collaborationMode
|
|
})
|
|
return
|
|
}
|
|
|
|
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.flavor === 'opencode') {
|
|
const { runOpencode } = await import('@/opencode/runOpencode')
|
|
await runOpencode({
|
|
existingSessionId: base.existingSessionId,
|
|
workingDirectory: base.workingDirectory,
|
|
resumeSessionId: base.resumeSessionId,
|
|
startedBy: base.startedBy,
|
|
permissionMode: base.permissionMode as OpencodePermissionMode | undefined,
|
|
startingMode: 'local',
|
|
model: target.model ?? undefined
|
|
})
|
|
return
|
|
}
|
|
|
|
if (target.flavor === 'kimi') {
|
|
const { runKimi } = await import('@/kimi/runKimi')
|
|
await runKimi({
|
|
existingSessionId: base.existingSessionId,
|
|
workingDirectory: base.workingDirectory,
|
|
resumeSessionId: base.resumeSessionId,
|
|
startedBy: base.startedBy,
|
|
permissionMode: base.permissionMode as KimiPermissionMode | undefined,
|
|
startingMode: 'local',
|
|
model: target.model ?? undefined
|
|
})
|
|
return
|
|
}
|
|
|
|
if (target.flavor === 'pi') {
|
|
const { runPi } = await import('@/pi/runPi')
|
|
await runPi({
|
|
existingSessionId: base.existingSessionId,
|
|
workingDirectory: base.workingDirectory,
|
|
resumeSessionId: base.resumeSessionId,
|
|
startedBy: base.startedBy,
|
|
// Pi runs as `pi --mode rpc` with piped stdio and no local TUI input
|
|
// path, so 'local' would advertise local-control that cannot be used
|
|
// and hide/reject remote-only controls until a web switch.
|
|
startingMode: 'remote',
|
|
model: target.model ?? undefined,
|
|
effort: target.effort ?? undefined,
|
|
})
|
|
return
|
|
}
|
|
|
|
const { runCursor } = await import('@/cursor/runCursor')
|
|
await runCursor({
|
|
existingSessionId: base.existingSessionId,
|
|
workingDirectory: base.workingDirectory,
|
|
resumeSessionId: base.resumeSessionId,
|
|
startedBy: base.startedBy,
|
|
permissionMode: base.permissionMode as CursorPermissionMode | undefined,
|
|
model: target.model ?? undefined
|
|
})
|
|
}
|
|
|
|
async function resolveSessionId(api: ApiClient, machineId: string, args: string[]): Promise<string> {
|
|
const explicit = args[0]
|
|
if (explicit) {
|
|
return explicit
|
|
}
|
|
|
|
const sessions = await api.listResumableSessions(machineId)
|
|
if (sessions.length === 0) {
|
|
throw new Error('No resumable sessions found for this machine')
|
|
}
|
|
|
|
if (!process.stdin.isTTY) {
|
|
for (const [index, session] of sessions.entries()) {
|
|
console.log(formatSessionLine(session, index))
|
|
}
|
|
throw new Error('Run: hapi resume <session-id>')
|
|
}
|
|
|
|
return await selectSession(sessions)
|
|
}
|
|
|
|
export const resumeCommand: CommandDefinition = {
|
|
name: 'resume',
|
|
requiresRuntimeAssets: true,
|
|
run: async ({ commandArgs }) => {
|
|
try {
|
|
await initializeToken()
|
|
await maybeAutoStartServer()
|
|
const { machineId } = await authAndSetupMachineIfNeeded()
|
|
const api = await ApiClient.create()
|
|
const sessionId = await resolveSessionId(api, machineId, commandArgs)
|
|
const target = await api.getLocalResumeTarget(sessionId)
|
|
|
|
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')
|
|
}
|
|
|
|
if (target.active) {
|
|
await api.handoffSessionToLocal(target.sessionId)
|
|
}
|
|
|
|
await dispatchLocalResume(target)
|
|
} catch (error) {
|
|
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')
|
|
if (process.env.DEBUG) {
|
|
console.error(error)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
}
|
|
}
|