Files
hapi/cli/src/commands/resume.ts
T
b44885ae67 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>
2026-06-29 11:42:41 +08:00

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)
}
}
}