import { writeFileSync } from 'node:fs' import type { ScrollBoxHandle } from '@hermes/ink' import { evictInkCaches } from '@hermes/ink' import { type RefObject, useCallback } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' import { ZERO } from '../domain/usage.js' import { type GatewayClient } from '../gatewayClient.js' import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse, SetupStatusResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) export const writeActiveSessionFile = (sessionId: null | string, file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE) => { if (!file || !sessionId) { return } try { writeFileSync(file, JSON.stringify({ session_id: sessionId }), { mode: 0o600 }) } catch { // Best-effort shell epilogue hint only; never break live session changes. } } const trimTail = (items: Msg[]) => { const q = [...items] while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { q.pop() } if (q.at(-1)?.role === 'user') { q.pop() } return q } export interface UseSessionLifecycleOptions { colsRef: { current: number } composerActions: ComposerActions gw: GatewayClient panel: (title: string, sections: PanelSection[]) => void rpc: GatewayRpc scrollRef: RefObject setHistoryItems: StateSetter setLastUserMsg: StateSetter setSessionStartedAt: StateSetter setStickyPrompt: StateSetter setVoiceProcessing: StateSetter setVoiceRecording: StateSetter sys: (text: string) => void } export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const { colsRef, composerActions, gw, panel, rpc, scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, setStickyPrompt, setVoiceProcessing, setVoiceRecording, sys } = opts const closeSession = useCallback( (targetSid?: null | string) => targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), [rpc] ) const resetSession = useCallback(() => { turnController.fullReset() setVoiceRecording(false) setVoiceProcessing(false) patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) setHistoryItems([]) setLastUserMsg('') setStickyPrompt('') composerActions.setPasteSnips([]) // Half-prune: new session has new keys, but keep a warm pool in case // the user resumes back to the prior session. evictInkCaches('half') }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) const resetVisibleHistory = useCallback( (info: null | SessionInfo = null) => { turnController.idle() turnController.clearReasoning() turnController.turnTools = [] turnController.persistedToolLabels.clear() setHistoryItems(info ? [introMsg(info)] : []) setStickyPrompt('') setLastUserMsg('') composerActions.setPasteSnips([]) patchTurnState({ activity: [] }) patchUiState({ info, usage: usageFrom(info) }) }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] ) const newSession = useCallback( async (msg?: string) => { const setup = await rpc('setup.status', {}) if (setup?.provider_configured === false) { panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) patchUiState({ status: 'setup required' }) return } await closeSession(getUiState().sid) const r = await rpc('session.create', { cols: colsRef.current }) if (!r) { return patchUiState({ status: 'ready' }) } const info = r.info ?? null resetSession() setSessionStartedAt(Date.now()) writeActiveSessionFile(r.session_id) patchUiState({ info, sid: r.session_id, status: info?.version ? 'ready' : 'starting agent…', usage: usageFrom(info) }) if (info) { setHistoryItems([introMsg(info)]) } if (info?.credential_warning) { sys(`warning: ${info.credential_warning}`) } if (info?.config_warning) { sys(`warning: ${info.config_warning}`) } if (msg) { sys(msg) } }, [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) const resumeById = useCallback( (id: string) => { patchOverlayState({ picker: false }) patchUiState({ status: 'resuming…' }) rpc('setup.status', {}).then(setup => { if (setup?.provider_configured === false) { panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) patchUiState({ status: 'setup required' }) return } closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => gw .request('session.resume', { cols: colsRef.current, session_id: id }) .then(raw => { const r = asRpcResult(raw) if (!r) { sys('error: invalid response: session.resume') return patchUiState({ status: 'ready' }) } resetSession() setSessionStartedAt(Date.now()) const resumed = toTranscriptMessages(r.messages) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) writeActiveSessionFile(r.resumed ?? r.session_id) patchUiState({ info: r.info ?? null, sid: r.session_id, status: 'ready', usage: usageFrom(r.info ?? null) }) setTimeout(() => scrollRef.current?.scrollToBottom(), 0) }) .catch((e: Error) => { sys(`error: ${e.message}`) patchUiState({ status: 'ready' }) }) ) }) }, [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys] ) const guardBusySessionSwitch = useCallback( (what = 'switch sessions') => { if (!getUiState().busy) { return false } sys(`interrupt the current turn before trying to ${what}`) return true }, [sys] ) return { closeSession, guardBusySessionSwitch, newSession, resetSession, resetVisibleHistory, resumeById, trimLastExchange: trimTail } }