import { atom } from 'nanostores' import { keyedTimeouts } from '@/lib/keyed-timeouts' import { $gateway } from './gateway' import { isSessionGone, isSessionGoneForBackgroundPolling, markSessionGone } from './runtime-gone' import { ambientRequestFor } from './session-gone-latch' import { requestForOwnedSession } from './session-states' export type GoalStatus = 'active' | 'done' | 'paused' | 'waiting' export interface SessionGoal { detail?: string status: GoalStatus title: string updatedAt: number } export const $goalsBySession = atom>({}) const DONE_LINGER_MS = 8_000 const clearTimers = keyedTimeouts() export function setSessionGoal(sid: string, goal: SessionGoal) { if (!sid) { return } clearTimers.cancel(sid) $goalsBySession.set({ ...$goalsBySession.get(), [sid]: goal }) if (goal.status === 'done') { clearTimers.schedule(sid, DONE_LINGER_MS, () => clearSessionGoal(sid)) } } export function clearSessionGoal(sid: string) { clearTimers.cancel(sid) const map = $goalsBySession.get() if (!(sid in map)) { return } const { [sid]: _drop, ...rest } = map $goalsBySession.set(rest) } const clean = (value: string): string => value.replace(/\r/g, '').trim() const firstLine = (value: string): string => clean(value).split('\n')[0]?.trim() ?? '' function goalTitleFromLine(line: string, pattern: RegExp): string { return (line.match(pattern)?.[1] ?? '').trim() } function nextGoalFromText(text: string, previous?: SessionGoal): SessionGoal | null | undefined { const body = clean(text) const line = firstLine(body) if (!line) { return undefined } if ( /^No active goal\b/i.test(line) || /^No goal (?:set|to resume)\b/i.test(line) || /^✓ Goal cleared\b/i.test(line) ) { return null } const now = Date.now() const fromSet = goalTitleFromLine(line, /^⊙ Goal set(?:\s*\([^)]*\))?:\s*(.+)$/) const fromActive = goalTitleFromLine(line, /^⊙ Goal\s*\([^)]*active[^)]*\):\s*(.+)$/) const fromResume = goalTitleFromLine(line, /^▶ Goal resumed:\s*(.+)$/) if (fromSet || fromActive || fromResume) { return { status: 'active', title: fromSet || fromActive || fromResume, updatedAt: now } } const fromWaiting = goalTitleFromLine(line, /^⏳ Goal\s*\([^)]*(?:parked|active)[^)]*\):\s*(.+)$/) if (fromWaiting) { return { status: 'waiting', title: fromWaiting, updatedAt: now } } const fromPaused = goalTitleFromLine(line, /^⏸ Goal(?:\s*\([^)]*\)| paused)?:\s*(.+)$/) if (fromPaused) { return { status: 'paused', title: fromPaused, updatedAt: now } } const fromDone = goalTitleFromLine(line, /^✓ Goal done\s*\([^)]*\):\s*(.+)$/) if (fromDone) { return { status: 'done', title: fromDone, updatedAt: now } } if (/^↻ Continuing toward goal\b/i.test(line)) { return { detail: line.replace(/^↻\s*/, ''), status: 'active', title: previous?.title || 'Standing goal', updatedAt: now } } if (/^⏳ Goal parked\b/i.test(line)) { return { detail: line.replace(/^⏳\s*/, ''), status: 'waiting', title: previous?.title || 'Standing goal', updatedAt: now } } if (/^⏸ Goal paused\b/i.test(line)) { return { detail: line.replace(/^⏸\s*/, ''), status: 'paused', title: previous?.title || 'Standing goal', updatedAt: now } } if (/^✓ Goal achieved\b/i.test(line)) { return { detail: line.replace(/^✓\s*/, ''), status: 'done', title: previous?.title || 'Standing goal', updatedAt: now } } return undefined } export function applyGoalStatusText(sid: string, text: string, opts?: { hydrate?: boolean }) { if (!sid) { return } const next = nextGoalFromText(text, $goalsBySession.get()[sid]) if (next === null) { clearSessionGoal(sid) } else if (next) { // A done goal is terminal state in the backend DB — it stays "done" // forever (only /goal clear or a new goal replaces it). The 8s linger is // for the LIVE completion moment; re-hydrating "✓ Goal done" on every // mount would resurrect the chip indefinitely. Bot Mode is the worst // case: one endless session means the completed layover would never go // away. On hydration, a terminal goal is the same as no goal. if (opts?.hydrate && next.status === 'done') { clearSessionGoal(sid) return } setSessionGoal(sid, next) } } export async function refreshSessionGoal(sid: string): Promise { const gateway = $gateway.get() if (!sid || !gateway || isSessionGone(sid)) { return } try { const result = await requestForOwnedSession<{ output?: string }>(sid, ambientRequestFor(gateway), 'slash.exec', { command: 'goal status', session_id: sid }) applyGoalStatusText(sid, result?.output ?? '', { hydrate: true }) } catch (error) { if (isSessionGoneForBackgroundPolling(error)) { markSessionGone(sid) return } // Best-effort: older gateways or a transport blip simply won't hydrate it. } }