diff --git a/packages/stores/health.svelte.ts b/packages/stores/health.svelte.ts new file mode 100644 index 0000000..e6d77d6 --- /dev/null +++ b/packages/stores/health.svelte.ts @@ -0,0 +1,329 @@ +// Project health tracking — Svelte 5 runes +// Tracks per-project activity state, burn rate, context pressure, and attention scoring + +import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '@agor/types'; +import { getAgentSession, type AgentSession } from '../../src/lib/stores/agents.svelte'; +import { getProjectConflicts } from '../../src/lib/stores/conflicts.svelte'; +import { scoreAttention } from '../../src/lib/utils/attention-scorer'; + +// --- Types --- + +export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; + +export interface ProjectHealth { + projectId: ProjectIdType; + sessionId: SessionIdType | null; + /** Current activity state */ + activityState: ActivityState; + /** Name of currently running tool (if any) */ + activeTool: string | null; + /** Duration in ms since last activity (0 if running a tool) */ + idleDurationMs: number; + /** Burn rate in USD per hour (0 if no data) */ + burnRatePerHour: number; + /** Context pressure as fraction 0..1 (null if unknown) */ + contextPressure: number | null; + /** Number of file conflicts (2+ agents writing same file) */ + fileConflictCount: number; + /** Number of external write conflicts (filesystem writes by non-agent processes) */ + externalConflictCount: number; + /** Attention urgency score (higher = more urgent, 0 = no attention needed) */ + attentionScore: number; + /** Human-readable attention reason */ + attentionReason: string | null; +} + +export type AttentionItem = ProjectHealth & { projectName: string; projectIcon: string }; + +// --- Configuration --- + +const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes +const TICK_INTERVAL_MS = 5_000; // Update derived state every 5s +const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for burn rate calc + +// Context limits by model (tokens) +const MODEL_CONTEXT_LIMITS: Record = { + 'claude-sonnet-4-20250514': 200_000, + 'claude-opus-4-20250514': 200_000, + 'claude-haiku-4-20250506': 200_000, + 'claude-3-5-sonnet-20241022': 200_000, + 'claude-3-5-haiku-20241022': 200_000, + 'claude-sonnet-4-6': 200_000, + 'claude-opus-4-6': 200_000, +}; +const DEFAULT_CONTEXT_LIMIT = 200_000; + + +// --- State --- + +interface ProjectTracker { + projectId: ProjectIdType; + sessionId: SessionIdType | null; + lastActivityTs: number; // epoch ms + lastToolName: string | null; + toolInFlight: boolean; + /** Token snapshots for burn rate calculation: [timestamp, totalTokens] */ + tokenSnapshots: Array<[number, number]>; + /** Cost snapshots for $/hr: [timestamp, costUsd] */ + costSnapshots: Array<[number, number]>; + /** Number of tasks in 'review' status (for reviewer agents) */ + reviewQueueDepth: number; +} + +let trackers = $state>(new Map()); +let stallThresholds = $state>(new Map()); // projectId → ms +let tickTs = $state(Date.now()); +let tickInterval: ReturnType | null = null; + +// --- Public API --- + +/** Register a project for health tracking */ +export function trackProject(projectId: ProjectIdType, sessionId: SessionIdType | null): void { + const existing = trackers.get(projectId); + if (existing) { + existing.sessionId = sessionId; + return; + } + trackers.set(projectId, { + projectId, + sessionId, + lastActivityTs: Date.now(), + lastToolName: null, + toolInFlight: false, + tokenSnapshots: [], + costSnapshots: [], + reviewQueueDepth: 0, + }); +} + +/** Remove a project from health tracking */ +export function untrackProject(projectId: ProjectIdType): void { + trackers.delete(projectId); +} + +/** Set per-project stall threshold in minutes (null to use default) */ +export function setStallThreshold(projectId: ProjectIdType, minutes: number | null): void { + if (minutes === null) { + stallThresholds.delete(projectId); + } else { + stallThresholds.set(projectId, minutes * 60 * 1000); + } +} + +/** Update session ID for a tracked project */ +export function updateProjectSession(projectId: ProjectIdType, sessionId: SessionIdType): void { + const t = trackers.get(projectId); + if (t) { + t.sessionId = sessionId; + } +} + +/** Record activity — call on every agent message. Auto-starts tick if stopped. */ +export function recordActivity(projectId: ProjectIdType, toolName?: string): void { + const t = trackers.get(projectId); + if (!t) return; + t.lastActivityTs = Date.now(); + if (toolName !== undefined) { + t.lastToolName = toolName; + t.toolInFlight = true; + } + // Auto-start tick when activity resumes + if (!tickInterval) startHealthTick(); +} + +/** Record tool completion */ +export function recordToolDone(projectId: ProjectIdType): void { + const t = trackers.get(projectId); + if (!t) return; + t.lastActivityTs = Date.now(); + t.toolInFlight = false; +} + +/** Record a token/cost snapshot for burn rate calculation */ +export function recordTokenSnapshot(projectId: ProjectIdType, totalTokens: number, costUsd: number): void { + const t = trackers.get(projectId); + if (!t) return; + const now = Date.now(); + t.tokenSnapshots.push([now, totalTokens]); + t.costSnapshots.push([now, costUsd]); + // Prune old snapshots beyond window + const cutoff = now - BURN_RATE_WINDOW_MS * 2; + t.tokenSnapshots = t.tokenSnapshots.filter(([ts]) => ts > cutoff); + t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff); +} + +/** Check if any tracked project has an active (running/starting) session */ +function hasActiveSession(): boolean { + for (const t of trackers.values()) { + if (!t.sessionId) continue; + const session = getAgentSession(t.sessionId); + if (session && (session.status === 'running' || session.status === 'starting')) return true; + } + return false; +} + +/** Start the health tick timer (auto-stops when no active sessions) */ +export function startHealthTick(): void { + if (tickInterval) return; + tickInterval = setInterval(() => { + if (!hasActiveSession()) { + stopHealthTick(); + return; + } + tickTs = Date.now(); + }, TICK_INTERVAL_MS); +} + +/** Stop the health tick timer */ +export function stopHealthTick(): void { + if (tickInterval) { + clearInterval(tickInterval); + tickInterval = null; + } +} + +/** Set review queue depth for a project (used by reviewer agents) */ +export function setReviewQueueDepth(projectId: ProjectIdType, depth: number): void { + const t = trackers.get(projectId); + if (t) t.reviewQueueDepth = depth; +} + +/** Clear all tracked projects */ +export function clearHealthTracking(): void { + trackers = new Map(); + stallThresholds = new Map(); +} + +// --- Derived health per project --- + +function getContextLimit(model?: string): number { + if (!model) return DEFAULT_CONTEXT_LIMIT; + return MODEL_CONTEXT_LIMITS[model] ?? DEFAULT_CONTEXT_LIMIT; +} + +function computeBurnRate(snapshots: Array<[number, number]>): number { + if (snapshots.length < 2) return 0; + const windowStart = Date.now() - BURN_RATE_WINDOW_MS; + const recent = snapshots.filter(([ts]) => ts >= windowStart); + if (recent.length < 2) return 0; + const first = recent[0]; + const last = recent[recent.length - 1]; + const elapsedHours = (last[0] - first[0]) / 3_600_000; + if (elapsedHours < 0.001) return 0; // Less than ~4 seconds + const costDelta = last[1] - first[1]; + return Math.max(0, costDelta / elapsedHours); +} + +function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { + const session: AgentSession | undefined = tracker.sessionId + ? getAgentSession(tracker.sessionId) + : undefined; + + // Activity state + let activityState: ActivityState; + let idleDurationMs = 0; + let activeTool: string | null = null; + + if (!session || session.status === 'idle' || session.status === 'done' || session.status === 'error') { + activityState = session?.status === 'error' ? 'inactive' : 'inactive'; + } else if (tracker.toolInFlight) { + activityState = 'running'; + activeTool = tracker.lastToolName; + idleDurationMs = 0; + } else { + idleDurationMs = now - tracker.lastActivityTs; + const stallMs = stallThresholds.get(tracker.projectId) ?? DEFAULT_STALL_THRESHOLD_MS; + if (idleDurationMs >= stallMs) { + activityState = 'stalled'; + } else { + activityState = 'idle'; + } + } + + // Context pressure + let contextPressure: number | null = null; + if (session && (session.inputTokens + session.outputTokens) > 0) { + const limit = getContextLimit(session.model); + contextPressure = Math.min(1, (session.inputTokens + session.outputTokens) / limit); + } + + // Burn rate + const burnRatePerHour = computeBurnRate(tracker.costSnapshots); + + // File conflicts + const conflicts = getProjectConflicts(tracker.projectId); + const fileConflictCount = conflicts.conflictCount; + const externalConflictCount = conflicts.externalConflictCount; + + // Attention scoring — delegated to pure function + const attention = scoreAttention({ + sessionStatus: session?.status, + sessionError: session?.error, + activityState, + idleDurationMs, + contextPressure, + fileConflictCount, + externalConflictCount, + reviewQueueDepth: tracker.reviewQueueDepth, + }); + + return { + projectId: tracker.projectId, + sessionId: tracker.sessionId, + activityState, + activeTool, + idleDurationMs, + burnRatePerHour, + contextPressure, + fileConflictCount, + externalConflictCount, + attentionScore: attention.score, + attentionReason: attention.reason, + }; +} + +/** Get health for a single project (reactive via tickTs) */ +export function getProjectHealth(projectId: ProjectIdType): ProjectHealth | null { + // Touch tickTs to make this reactive to the timer + const now = tickTs; + const t = trackers.get(projectId); + if (!t) return null; + return computeHealth(t, now); +} + +/** Get all project health sorted by attention score descending */ +export function getAllProjectHealth(): ProjectHealth[] { + const now = tickTs; + const results: ProjectHealth[] = []; + for (const t of trackers.values()) { + results.push(computeHealth(t, now)); + } + results.sort((a, b) => b.attentionScore - a.attentionScore); + return results; +} + +/** Get top N items needing attention */ +export function getAttentionQueue(limit = 5): ProjectHealth[] { + return getAllProjectHealth().filter(h => h.attentionScore > 0).slice(0, limit); +} + +/** Get aggregate stats across all tracked projects */ +export function getHealthAggregates(): { + running: number; + idle: number; + stalled: number; + totalBurnRatePerHour: number; +} { + const all = getAllProjectHealth(); + let running = 0; + let idle = 0; + let stalled = 0; + let totalBurnRatePerHour = 0; + for (const h of all) { + if (h.activityState === 'running') running++; + else if (h.activityState === 'idle') idle++; + else if (h.activityState === 'stalled') stalled++; + totalBurnRatePerHour += h.burnRatePerHour; + } + return { running, idle, stalled, totalBurnRatePerHour }; +} diff --git a/packages/stores/index.ts b/packages/stores/index.ts new file mode 100644 index 0000000..a692c50 --- /dev/null +++ b/packages/stores/index.ts @@ -0,0 +1,5 @@ +// @agor/stores — shared store modules for Tauri and Electrobun frontends + +export * from './theme.svelte'; +export * from './notifications.svelte'; +export * from './health.svelte'; diff --git a/packages/stores/notifications.svelte.ts b/packages/stores/notifications.svelte.ts new file mode 100644 index 0000000..8d1fa96 --- /dev/null +++ b/packages/stores/notifications.svelte.ts @@ -0,0 +1,170 @@ +// Notification store — ephemeral toasts + persistent notification history + +import { getBackend } from '../../src/lib/backend/backend'; + +// --- Toast types (existing) --- + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +export interface Toast { + id: string; + type: ToastType; + message: string; + timestamp: number; +} + +// --- Notification history types (new) --- + +export type NotificationType = + | 'agent_complete' + | 'agent_error' + | 'task_review' + | 'wake_event' + | 'conflict' + | 'system'; + +export interface HistoryNotification { + id: string; + title: string; + body: string; + type: NotificationType; + timestamp: number; + read: boolean; + projectId?: string; +} + +// --- State --- + +let toasts = $state([]); +let notificationHistory = $state([]); + +const MAX_TOASTS = 5; +const TOAST_DURATION_MS = 4000; +const MAX_HISTORY = 100; + +// --- Rate limiting (prevents toast flood from hot paths) --- + +const RATE_LIMIT_WINDOW_MS = 30_000; +const RATE_LIMIT_MAX_PER_TYPE = 3; +const recentToasts = new Map(); + +function isRateLimited(type: ToastType): boolean { + const now = Date.now(); + const timestamps = recentToasts.get(type) ?? []; + const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); + recentToasts.set(type, recent); + if (recent.length >= RATE_LIMIT_MAX_PER_TYPE) return true; + recent.push(now); + return false; +} + +// --- Toast API (preserved from original) --- + +export function getNotifications(): Toast[] { + return toasts; +} + +export function notify(type: ToastType, message: string): string { + if (isRateLimited(type)) return ''; + + const id = crypto.randomUUID(); + toasts.push({ id, type, message, timestamp: Date.now() }); + + // Cap visible toasts + if (toasts.length > MAX_TOASTS) { + toasts = toasts.slice(-MAX_TOASTS); + } + + // Auto-dismiss + setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); + + return id; +} + +export function dismissNotification(id: string): void { + toasts = toasts.filter(n => n.id !== id); +} + +// --- Notification History API (new) --- + +/** Map NotificationType to a toast type for the ephemeral toast */ +function notificationTypeToToast(type: NotificationType): ToastType { + switch (type) { + case 'agent_complete': return 'success'; + case 'agent_error': return 'error'; + case 'task_review': return 'info'; + case 'wake_event': return 'info'; + case 'conflict': return 'warning'; + case 'system': return 'info'; + } +} + +/** Map NotificationType to OS notification urgency */ +function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' { + switch (type) { + case 'agent_error': return 'critical'; + case 'conflict': return 'normal'; + case 'system': return 'normal'; + default: return 'low'; + } +} + +/** + * Add a notification to history, show a toast, and send an OS desktop notification. + */ +export function addNotification( + title: string, + body: string, + type: NotificationType, + projectId?: string, +): string { + const id = crypto.randomUUID(); + + // Add to history + notificationHistory.push({ + id, + title, + body, + type, + timestamp: Date.now(), + read: false, + projectId, + }); + + // Cap history + if (notificationHistory.length > MAX_HISTORY) { + notificationHistory = notificationHistory.slice(-MAX_HISTORY); + } + + // Show ephemeral toast + const toastType = notificationTypeToToast(type); + notify(toastType, `${title}: ${body}`); + + // Send OS desktop notification (fire-and-forget) + try { getBackend().sendDesktopNotification(title, body, notificationUrgency(type)); } catch { /* backend not ready */ } + + return id; +} + +export function getNotificationHistory(): HistoryNotification[] { + return notificationHistory; +} + +export function getUnreadCount(): number { + return notificationHistory.filter(n => !n.read).length; +} + +export function markRead(id: string): void { + const entry = notificationHistory.find(n => n.id === id); + if (entry) entry.read = true; +} + +export function markAllRead(): void { + for (const entry of notificationHistory) { + entry.read = true; + } +} + +export function clearHistory(): void { + notificationHistory = []; +} diff --git a/packages/stores/package.json b/packages/stores/package.json new file mode 100644 index 0000000..54bdf1d --- /dev/null +++ b/packages/stores/package.json @@ -0,0 +1,13 @@ +{ + "name": "@agor/stores", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "index.ts", + "types": "index.ts", + "exports": { + ".": "./index.ts", + "./*": "./*.ts" + }, + "files": ["*.ts"] +} diff --git a/packages/stores/theme.svelte.ts b/packages/stores/theme.svelte.ts new file mode 100644 index 0000000..c6d780d --- /dev/null +++ b/packages/stores/theme.svelte.ts @@ -0,0 +1,151 @@ +// Theme store — persists theme selection via settings bridge + +import { getSetting, setSetting } from '../../src/lib/stores/settings-store.svelte'; +import { handleInfraError } from '../../src/lib/utils/handle-error'; +import { + type ThemeId, + type ThemePalette, + type CatppuccinFlavor, + ALL_THEME_IDS, + buildXtermTheme, + buildXtermThemeFromPalette, + applyCssVariables, + applyPaletteDirect, + type XtermTheme, +} from '../../src/lib/styles/themes'; + +let currentTheme = $state('mocha'); +let customPalette = $state(null); + +/** Registered theme-change listeners */ +const themeChangeCallbacks = new Set<() => void>(); + +/** Register a callback invoked after every theme change. Returns an unsubscribe function. */ +export function onThemeChange(callback: () => void): () => void { + themeChangeCallbacks.add(callback); + return () => { + themeChangeCallbacks.delete(callback); + }; +} + +export function getCurrentTheme(): ThemeId { + return currentTheme; +} + +/** @deprecated Use getCurrentTheme() */ +export function getCurrentFlavor(): CatppuccinFlavor { + // Return valid CatppuccinFlavor or default to 'mocha' + const catFlavors: string[] = ['latte', 'frappe', 'macchiato', 'mocha']; + return catFlavors.includes(currentTheme) ? currentTheme as CatppuccinFlavor : 'mocha'; +} + +export function getXtermTheme(): XtermTheme { + if (customPalette) return buildXtermThemeFromPalette(customPalette); + return buildXtermTheme(currentTheme); +} + +/** Apply an arbitrary palette for live preview (does NOT persist) */ +export function previewPalette(palette: ThemePalette): void { + customPalette = palette; + applyPaletteDirect(palette); + for (const cb of themeChangeCallbacks) { + try { cb(); } catch (e) { handleInfraError(e, 'theme.previewCallback'); } + } +} + +/** Clear custom palette preview, revert to current built-in theme */ +export function clearPreview(): void { + customPalette = null; + applyCssVariables(currentTheme); + for (const cb of themeChangeCallbacks) { + try { cb(); } catch (e) { handleInfraError(e, 'theme.clearPreviewCallback'); } + } +} + +/** Set a custom theme as active (persists the custom theme ID) */ +export async function setCustomTheme(id: string, palette: ThemePalette): Promise { + customPalette = palette; + applyPaletteDirect(palette); + for (const cb of themeChangeCallbacks) { + try { cb(); } catch (e) { handleInfraError(e, 'theme.customCallback'); } + } + try { + await setSetting('theme', id); + } catch (e) { + handleInfraError(e, 'theme.persistCustom'); + } +} + +/** Check if current theme is a custom theme */ +export function isCustomThemeActive(): boolean { + return customPalette !== null; +} + +/** Change theme, apply CSS variables, and persist to settings DB */ +export async function setTheme(theme: ThemeId): Promise { + currentTheme = theme; + applyCssVariables(theme); + // Notify all listeners (e.g. open xterm.js terminals) + for (const cb of themeChangeCallbacks) { + try { + cb(); + } catch (e) { + handleInfraError(e, 'theme.changeCallback'); + } + } + + try { + await setSetting('theme', theme); + } catch (e) { + handleInfraError(e, 'theme.persistSetting'); + } +} + +/** @deprecated Use setTheme() */ +export async function setFlavor(flavor: CatppuccinFlavor): Promise { + return setTheme(flavor); +} + +/** Load saved theme from settings DB and apply. Call once on app startup. */ +export async function initTheme(): Promise { + try { + const saved = await getSetting('theme'); + if (saved) { + if (saved.startsWith('custom:')) { + // Custom theme — load palette from custom_themes storage + const { loadCustomThemes } = await import('../../src/lib/styles/custom-themes'); + const customs = await loadCustomThemes(); + const match = customs.find(c => c.id === saved); + if (match) { + customPalette = match.palette; + applyPaletteDirect(match.palette); + } + } else if (ALL_THEME_IDS.includes(saved as ThemeId)) { + currentTheme = saved as ThemeId; + } + } + } catch { + // Fall back to default (mocha) — catppuccin.css provides Mocha defaults + } + // Always apply to sync CSS vars with current theme (skip if custom already applied) + if (!customPalette && currentTheme !== 'mocha') { + applyCssVariables(currentTheme); + } + + // Apply saved font settings + try { + const [uiFont, uiSize, termFont, termSize] = await Promise.all([ + getSetting('ui_font_family'), + getSetting('ui_font_size'), + getSetting('term_font_family'), + getSetting('term_font_size'), + ]); + const root = document.documentElement.style; + if (uiFont) root.setProperty('--ui-font-family', `'${uiFont}', sans-serif`); + if (uiSize) root.setProperty('--ui-font-size', `${uiSize}px`); + if (termFont) root.setProperty('--term-font-family', `'${termFont}', monospace`); + if (termSize) root.setProperty('--term-font-size', `${termSize}px`); + } catch { + // Font settings are optional — defaults from catppuccin.css apply + } +} diff --git a/packages/stores/tsconfig.json b/packages/stores/tsconfig.json new file mode 100644 index 0000000..a15b790 --- /dev/null +++ b/packages/stores/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "paths": { + "@agor/types": ["../types/index.ts"], + "@agor/types/*": ["../types/*.ts"] + } + }, + "include": ["*.ts"], + "references": [ + { "path": "../types" } + ] +} diff --git a/src/lib/stores/health.svelte.ts b/src/lib/stores/health.svelte.ts index c7cb0bb..426803e 100644 --- a/src/lib/stores/health.svelte.ts +++ b/src/lib/stores/health.svelte.ts @@ -1,329 +1,21 @@ -// Project health tracking — Svelte 5 runes -// Tracks per-project activity state, burn rate, context pressure, and attention scoring - -import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; -import { getAgentSession, type AgentSession } from './agents.svelte'; -import { getProjectConflicts } from './conflicts.svelte'; -import { scoreAttention } from '../utils/attention-scorer'; - -// --- Types --- - -export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; - -export interface ProjectHealth { - projectId: ProjectIdType; - sessionId: SessionIdType | null; - /** Current activity state */ - activityState: ActivityState; - /** Name of currently running tool (if any) */ - activeTool: string | null; - /** Duration in ms since last activity (0 if running a tool) */ - idleDurationMs: number; - /** Burn rate in USD per hour (0 if no data) */ - burnRatePerHour: number; - /** Context pressure as fraction 0..1 (null if unknown) */ - contextPressure: number | null; - /** Number of file conflicts (2+ agents writing same file) */ - fileConflictCount: number; - /** Number of external write conflicts (filesystem writes by non-agent processes) */ - externalConflictCount: number; - /** Attention urgency score (higher = more urgent, 0 = no attention needed) */ - attentionScore: number; - /** Human-readable attention reason */ - attentionReason: string | null; -} - -export type AttentionItem = ProjectHealth & { projectName: string; projectIcon: string }; - -// --- Configuration --- - -const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes -const TICK_INTERVAL_MS = 5_000; // Update derived state every 5s -const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for burn rate calc - -// Context limits by model (tokens) -const MODEL_CONTEXT_LIMITS: Record = { - 'claude-sonnet-4-20250514': 200_000, - 'claude-opus-4-20250514': 200_000, - 'claude-haiku-4-20250506': 200_000, - 'claude-3-5-sonnet-20241022': 200_000, - 'claude-3-5-haiku-20241022': 200_000, - 'claude-sonnet-4-6': 200_000, - 'claude-opus-4-6': 200_000, -}; -const DEFAULT_CONTEXT_LIMIT = 200_000; - - -// --- State --- - -interface ProjectTracker { - projectId: ProjectIdType; - sessionId: SessionIdType | null; - lastActivityTs: number; // epoch ms - lastToolName: string | null; - toolInFlight: boolean; - /** Token snapshots for burn rate calculation: [timestamp, totalTokens] */ - tokenSnapshots: Array<[number, number]>; - /** Cost snapshots for $/hr: [timestamp, costUsd] */ - costSnapshots: Array<[number, number]>; - /** Number of tasks in 'review' status (for reviewer agents) */ - reviewQueueDepth: number; -} - -let trackers = $state>(new Map()); -let stallThresholds = $state>(new Map()); // projectId → ms -let tickTs = $state(Date.now()); -let tickInterval: ReturnType | null = null; - -// --- Public API --- - -/** Register a project for health tracking */ -export function trackProject(projectId: ProjectIdType, sessionId: SessionIdType | null): void { - const existing = trackers.get(projectId); - if (existing) { - existing.sessionId = sessionId; - return; - } - trackers.set(projectId, { - projectId, - sessionId, - lastActivityTs: Date.now(), - lastToolName: null, - toolInFlight: false, - tokenSnapshots: [], - costSnapshots: [], - reviewQueueDepth: 0, - }); -} - -/** Remove a project from health tracking */ -export function untrackProject(projectId: ProjectIdType): void { - trackers.delete(projectId); -} - -/** Set per-project stall threshold in minutes (null to use default) */ -export function setStallThreshold(projectId: ProjectIdType, minutes: number | null): void { - if (minutes === null) { - stallThresholds.delete(projectId); - } else { - stallThresholds.set(projectId, minutes * 60 * 1000); - } -} - -/** Update session ID for a tracked project */ -export function updateProjectSession(projectId: ProjectIdType, sessionId: SessionIdType): void { - const t = trackers.get(projectId); - if (t) { - t.sessionId = sessionId; - } -} - -/** Record activity — call on every agent message. Auto-starts tick if stopped. */ -export function recordActivity(projectId: ProjectIdType, toolName?: string): void { - const t = trackers.get(projectId); - if (!t) return; - t.lastActivityTs = Date.now(); - if (toolName !== undefined) { - t.lastToolName = toolName; - t.toolInFlight = true; - } - // Auto-start tick when activity resumes - if (!tickInterval) startHealthTick(); -} - -/** Record tool completion */ -export function recordToolDone(projectId: ProjectIdType): void { - const t = trackers.get(projectId); - if (!t) return; - t.lastActivityTs = Date.now(); - t.toolInFlight = false; -} - -/** Record a token/cost snapshot for burn rate calculation */ -export function recordTokenSnapshot(projectId: ProjectIdType, totalTokens: number, costUsd: number): void { - const t = trackers.get(projectId); - if (!t) return; - const now = Date.now(); - t.tokenSnapshots.push([now, totalTokens]); - t.costSnapshots.push([now, costUsd]); - // Prune old snapshots beyond window - const cutoff = now - BURN_RATE_WINDOW_MS * 2; - t.tokenSnapshots = t.tokenSnapshots.filter(([ts]) => ts > cutoff); - t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff); -} - -/** Check if any tracked project has an active (running/starting) session */ -function hasActiveSession(): boolean { - for (const t of trackers.values()) { - if (!t.sessionId) continue; - const session = getAgentSession(t.sessionId); - if (session && (session.status === 'running' || session.status === 'starting')) return true; - } - return false; -} - -/** Start the health tick timer (auto-stops when no active sessions) */ -export function startHealthTick(): void { - if (tickInterval) return; - tickInterval = setInterval(() => { - if (!hasActiveSession()) { - stopHealthTick(); - return; - } - tickTs = Date.now(); - }, TICK_INTERVAL_MS); -} - -/** Stop the health tick timer */ -export function stopHealthTick(): void { - if (tickInterval) { - clearInterval(tickInterval); - tickInterval = null; - } -} - -/** Set review queue depth for a project (used by reviewer agents) */ -export function setReviewQueueDepth(projectId: ProjectIdType, depth: number): void { - const t = trackers.get(projectId); - if (t) t.reviewQueueDepth = depth; -} - -/** Clear all tracked projects */ -export function clearHealthTracking(): void { - trackers = new Map(); - stallThresholds = new Map(); -} - -// --- Derived health per project --- - -function getContextLimit(model?: string): number { - if (!model) return DEFAULT_CONTEXT_LIMIT; - return MODEL_CONTEXT_LIMITS[model] ?? DEFAULT_CONTEXT_LIMIT; -} - -function computeBurnRate(snapshots: Array<[number, number]>): number { - if (snapshots.length < 2) return 0; - const windowStart = Date.now() - BURN_RATE_WINDOW_MS; - const recent = snapshots.filter(([ts]) => ts >= windowStart); - if (recent.length < 2) return 0; - const first = recent[0]; - const last = recent[recent.length - 1]; - const elapsedHours = (last[0] - first[0]) / 3_600_000; - if (elapsedHours < 0.001) return 0; // Less than ~4 seconds - const costDelta = last[1] - first[1]; - return Math.max(0, costDelta / elapsedHours); -} - -function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { - const session: AgentSession | undefined = tracker.sessionId - ? getAgentSession(tracker.sessionId) - : undefined; - - // Activity state - let activityState: ActivityState; - let idleDurationMs = 0; - let activeTool: string | null = null; - - if (!session || session.status === 'idle' || session.status === 'done' || session.status === 'error') { - activityState = session?.status === 'error' ? 'inactive' : 'inactive'; - } else if (tracker.toolInFlight) { - activityState = 'running'; - activeTool = tracker.lastToolName; - idleDurationMs = 0; - } else { - idleDurationMs = now - tracker.lastActivityTs; - const stallMs = stallThresholds.get(tracker.projectId) ?? DEFAULT_STALL_THRESHOLD_MS; - if (idleDurationMs >= stallMs) { - activityState = 'stalled'; - } else { - activityState = 'idle'; - } - } - - // Context pressure - let contextPressure: number | null = null; - if (session && (session.inputTokens + session.outputTokens) > 0) { - const limit = getContextLimit(session.model); - contextPressure = Math.min(1, (session.inputTokens + session.outputTokens) / limit); - } - - // Burn rate - const burnRatePerHour = computeBurnRate(tracker.costSnapshots); - - // File conflicts - const conflicts = getProjectConflicts(tracker.projectId); - const fileConflictCount = conflicts.conflictCount; - const externalConflictCount = conflicts.externalConflictCount; - - // Attention scoring — delegated to pure function - const attention = scoreAttention({ - sessionStatus: session?.status, - sessionError: session?.error, - activityState, - idleDurationMs, - contextPressure, - fileConflictCount, - externalConflictCount, - reviewQueueDepth: tracker.reviewQueueDepth, - }); - - return { - projectId: tracker.projectId, - sessionId: tracker.sessionId, - activityState, - activeTool, - idleDurationMs, - burnRatePerHour, - contextPressure, - fileConflictCount, - externalConflictCount, - attentionScore: attention.score, - attentionReason: attention.reason, - }; -} - -/** Get health for a single project (reactive via tickTs) */ -export function getProjectHealth(projectId: ProjectIdType): ProjectHealth | null { - // Touch tickTs to make this reactive to the timer - const now = tickTs; - const t = trackers.get(projectId); - if (!t) return null; - return computeHealth(t, now); -} - -/** Get all project health sorted by attention score descending */ -export function getAllProjectHealth(): ProjectHealth[] { - const now = tickTs; - const results: ProjectHealth[] = []; - for (const t of trackers.values()) { - results.push(computeHealth(t, now)); - } - results.sort((a, b) => b.attentionScore - a.attentionScore); - return results; -} - -/** Get top N items needing attention */ -export function getAttentionQueue(limit = 5): ProjectHealth[] { - return getAllProjectHealth().filter(h => h.attentionScore > 0).slice(0, limit); -} - -/** Get aggregate stats across all tracked projects */ -export function getHealthAggregates(): { - running: number; - idle: number; - stalled: number; - totalBurnRatePerHour: number; -} { - const all = getAllProjectHealth(); - let running = 0; - let idle = 0; - let stalled = 0; - let totalBurnRatePerHour = 0; - for (const h of all) { - if (h.activityState === 'running') running++; - else if (h.activityState === 'idle') idle++; - else if (h.activityState === 'stalled') stalled++; - totalBurnRatePerHour += h.burnRatePerHour; - } - return { running, idle, stalled, totalBurnRatePerHour }; -} +// Re-export from @agor/stores package +export { + type ActivityState, + type ProjectHealth, + type AttentionItem, + trackProject, + untrackProject, + setStallThreshold, + updateProjectSession, + recordActivity, + recordToolDone, + recordTokenSnapshot, + startHealthTick, + stopHealthTick, + setReviewQueueDepth, + clearHealthTracking, + getProjectHealth, + getAllProjectHealth, + getAttentionQueue, + getHealthAggregates, +} from '@agor/stores/health.svelte'; diff --git a/src/lib/stores/notifications.svelte.ts b/src/lib/stores/notifications.svelte.ts index 9bccf5e..b5f7196 100644 --- a/src/lib/stores/notifications.svelte.ts +++ b/src/lib/stores/notifications.svelte.ts @@ -1,170 +1,16 @@ -// Notification store — ephemeral toasts + persistent notification history - -import { getBackend } from '../backend/backend'; - -// --- Toast types (existing) --- - -export type ToastType = 'info' | 'success' | 'warning' | 'error'; - -export interface Toast { - id: string; - type: ToastType; - message: string; - timestamp: number; -} - -// --- Notification history types (new) --- - -export type NotificationType = - | 'agent_complete' - | 'agent_error' - | 'task_review' - | 'wake_event' - | 'conflict' - | 'system'; - -export interface HistoryNotification { - id: string; - title: string; - body: string; - type: NotificationType; - timestamp: number; - read: boolean; - projectId?: string; -} - -// --- State --- - -let toasts = $state([]); -let notificationHistory = $state([]); - -const MAX_TOASTS = 5; -const TOAST_DURATION_MS = 4000; -const MAX_HISTORY = 100; - -// --- Rate limiting (prevents toast flood from hot paths) --- - -const RATE_LIMIT_WINDOW_MS = 30_000; -const RATE_LIMIT_MAX_PER_TYPE = 3; -const recentToasts = new Map(); - -function isRateLimited(type: ToastType): boolean { - const now = Date.now(); - const timestamps = recentToasts.get(type) ?? []; - const recent = timestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS); - recentToasts.set(type, recent); - if (recent.length >= RATE_LIMIT_MAX_PER_TYPE) return true; - recent.push(now); - return false; -} - -// --- Toast API (preserved from original) --- - -export function getNotifications(): Toast[] { - return toasts; -} - -export function notify(type: ToastType, message: string): string { - if (isRateLimited(type)) return ''; - - const id = crypto.randomUUID(); - toasts.push({ id, type, message, timestamp: Date.now() }); - - // Cap visible toasts - if (toasts.length > MAX_TOASTS) { - toasts = toasts.slice(-MAX_TOASTS); - } - - // Auto-dismiss - setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); - - return id; -} - -export function dismissNotification(id: string): void { - toasts = toasts.filter(n => n.id !== id); -} - -// --- Notification History API (new) --- - -/** Map NotificationType to a toast type for the ephemeral toast */ -function notificationTypeToToast(type: NotificationType): ToastType { - switch (type) { - case 'agent_complete': return 'success'; - case 'agent_error': return 'error'; - case 'task_review': return 'info'; - case 'wake_event': return 'info'; - case 'conflict': return 'warning'; - case 'system': return 'info'; - } -} - -/** Map NotificationType to OS notification urgency */ -function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' { - switch (type) { - case 'agent_error': return 'critical'; - case 'conflict': return 'normal'; - case 'system': return 'normal'; - default: return 'low'; - } -} - -/** - * Add a notification to history, show a toast, and send an OS desktop notification. - */ -export function addNotification( - title: string, - body: string, - type: NotificationType, - projectId?: string, -): string { - const id = crypto.randomUUID(); - - // Add to history - notificationHistory.push({ - id, - title, - body, - type, - timestamp: Date.now(), - read: false, - projectId, - }); - - // Cap history - if (notificationHistory.length > MAX_HISTORY) { - notificationHistory = notificationHistory.slice(-MAX_HISTORY); - } - - // Show ephemeral toast - const toastType = notificationTypeToToast(type); - notify(toastType, `${title}: ${body}`); - - // Send OS desktop notification (fire-and-forget) - try { getBackend().sendDesktopNotification(title, body, notificationUrgency(type)); } catch { /* backend not ready */ } - - return id; -} - -export function getNotificationHistory(): HistoryNotification[] { - return notificationHistory; -} - -export function getUnreadCount(): number { - return notificationHistory.filter(n => !n.read).length; -} - -export function markRead(id: string): void { - const entry = notificationHistory.find(n => n.id === id); - if (entry) entry.read = true; -} - -export function markAllRead(): void { - for (const entry of notificationHistory) { - entry.read = true; - } -} - -export function clearHistory(): void { - notificationHistory = []; -} +// Re-export from @agor/stores package +export { + type ToastType, + type Toast, + type NotificationType, + type HistoryNotification, + getNotifications, + notify, + dismissNotification, + addNotification, + getNotificationHistory, + getUnreadCount, + markRead, + markAllRead, + clearHistory, +} from '@agor/stores/notifications.svelte'; diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 06c6550..212cd0d 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -1,151 +1,14 @@ -// Theme store — persists theme selection via settings bridge - -import { getSetting, setSetting } from './settings-store.svelte'; -import { handleInfraError } from '../utils/handle-error'; -import { - type ThemeId, - type ThemePalette, - type CatppuccinFlavor, - ALL_THEME_IDS, - buildXtermTheme, - buildXtermThemeFromPalette, - applyCssVariables, - applyPaletteDirect, - type XtermTheme, -} from '../styles/themes'; - -let currentTheme = $state('mocha'); -let customPalette = $state(null); - -/** Registered theme-change listeners */ -const themeChangeCallbacks = new Set<() => void>(); - -/** Register a callback invoked after every theme change. Returns an unsubscribe function. */ -export function onThemeChange(callback: () => void): () => void { - themeChangeCallbacks.add(callback); - return () => { - themeChangeCallbacks.delete(callback); - }; -} - -export function getCurrentTheme(): ThemeId { - return currentTheme; -} - -/** @deprecated Use getCurrentTheme() */ -export function getCurrentFlavor(): CatppuccinFlavor { - // Return valid CatppuccinFlavor or default to 'mocha' - const catFlavors: string[] = ['latte', 'frappe', 'macchiato', 'mocha']; - return catFlavors.includes(currentTheme) ? currentTheme as CatppuccinFlavor : 'mocha'; -} - -export function getXtermTheme(): XtermTheme { - if (customPalette) return buildXtermThemeFromPalette(customPalette); - return buildXtermTheme(currentTheme); -} - -/** Apply an arbitrary palette for live preview (does NOT persist) */ -export function previewPalette(palette: ThemePalette): void { - customPalette = palette; - applyPaletteDirect(palette); - for (const cb of themeChangeCallbacks) { - try { cb(); } catch (e) { handleInfraError(e, 'theme.previewCallback'); } - } -} - -/** Clear custom palette preview, revert to current built-in theme */ -export function clearPreview(): void { - customPalette = null; - applyCssVariables(currentTheme); - for (const cb of themeChangeCallbacks) { - try { cb(); } catch (e) { handleInfraError(e, 'theme.clearPreviewCallback'); } - } -} - -/** Set a custom theme as active (persists the custom theme ID) */ -export async function setCustomTheme(id: string, palette: ThemePalette): Promise { - customPalette = palette; - applyPaletteDirect(palette); - for (const cb of themeChangeCallbacks) { - try { cb(); } catch (e) { handleInfraError(e, 'theme.customCallback'); } - } - try { - await setSetting('theme', id); - } catch (e) { - handleInfraError(e, 'theme.persistCustom'); - } -} - -/** Check if current theme is a custom theme */ -export function isCustomThemeActive(): boolean { - return customPalette !== null; -} - -/** Change theme, apply CSS variables, and persist to settings DB */ -export async function setTheme(theme: ThemeId): Promise { - currentTheme = theme; - applyCssVariables(theme); - // Notify all listeners (e.g. open xterm.js terminals) - for (const cb of themeChangeCallbacks) { - try { - cb(); - } catch (e) { - handleInfraError(e, 'theme.changeCallback'); - } - } - - try { - await setSetting('theme', theme); - } catch (e) { - handleInfraError(e, 'theme.persistSetting'); - } -} - -/** @deprecated Use setTheme() */ -export async function setFlavor(flavor: CatppuccinFlavor): Promise { - return setTheme(flavor); -} - -/** Load saved theme from settings DB and apply. Call once on app startup. */ -export async function initTheme(): Promise { - try { - const saved = await getSetting('theme'); - if (saved) { - if (saved.startsWith('custom:')) { - // Custom theme — load palette from custom_themes storage - const { loadCustomThemes } = await import('../styles/custom-themes'); - const customs = await loadCustomThemes(); - const match = customs.find(c => c.id === saved); - if (match) { - customPalette = match.palette; - applyPaletteDirect(match.palette); - } - } else if (ALL_THEME_IDS.includes(saved as ThemeId)) { - currentTheme = saved as ThemeId; - } - } - } catch { - // Fall back to default (mocha) — catppuccin.css provides Mocha defaults - } - // Always apply to sync CSS vars with current theme (skip if custom already applied) - if (!customPalette && currentTheme !== 'mocha') { - applyCssVariables(currentTheme); - } - - // Apply saved font settings - try { - const [uiFont, uiSize, termFont, termSize] = await Promise.all([ - getSetting('ui_font_family'), - getSetting('ui_font_size'), - getSetting('term_font_family'), - getSetting('term_font_size'), - ]); - const root = document.documentElement.style; - if (uiFont) root.setProperty('--ui-font-family', `'${uiFont}', sans-serif`); - if (uiSize) root.setProperty('--ui-font-size', `${uiSize}px`); - if (termFont) root.setProperty('--term-font-family', `'${termFont}', monospace`); - if (termSize) root.setProperty('--term-font-size', `${termSize}px`); - } catch { - // Font settings are optional — defaults from catppuccin.css apply - } -} +// Re-export from @agor/stores package +export { + onThemeChange, + getCurrentTheme, + getCurrentFlavor, + getXtermTheme, + previewPalette, + clearPreview, + setCustomTheme, + isCustomThemeActive, + setTheme, + setFlavor, + initTheme, +} from '@agor/stores/theme.svelte'; diff --git a/ui-electrobun/src/bun/handlers/files-handlers.ts b/ui-electrobun/src/bun/handlers/files-handlers.ts index bd3ae3f..87ff6da 100644 --- a/ui-electrobun/src/bun/handlers/files-handlers.ts +++ b/ui-electrobun/src/bun/handlers/files-handlers.ts @@ -76,6 +76,21 @@ export function createFilesHandlers() { } }, + // Feature 2: Get file stat (mtime) for conflict detection + "files.stat": async ({ path: filePath }: { path: string }) => { + const guard = guardPath(filePath); + if (!guard.valid) { + return { mtimeMs: 0, size: 0, error: guard.error }; + } + try { + const stat = fs.statSync(guard.resolved); + return { mtimeMs: stat.mtimeMs, size: stat.size }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + return { mtimeMs: 0, size: 0, error }; + } + }, + "files.write": async ({ path: filePath, content }: { path: string; content: string }) => { const guard = guardPath(filePath); if (!guard.valid) { @@ -83,7 +98,10 @@ export function createFilesHandlers() { return { ok: false, error: guard.error }; } try { - fs.writeFileSync(guard.resolved, content, "utf8"); + // Feature 2: Atomic write via temp file + rename + const tmpPath = guard.resolved + ".agor-tmp"; + fs.writeFileSync(tmpPath, content, "utf8"); + fs.renameSync(tmpPath, guard.resolved); return { ok: true }; } catch (err) { const error = err instanceof Error ? err.message : String(err); diff --git a/ui-electrobun/src/mainview/FileBrowser.svelte b/ui-electrobun/src/mainview/FileBrowser.svelte index 7e20eab..fb90b05 100644 --- a/ui-electrobun/src/mainview/FileBrowser.svelte +++ b/ui-electrobun/src/mainview/FileBrowser.svelte @@ -32,6 +32,9 @@ let editorContent = $state(''); // Fix #6: Request token to discard stale file load responses let fileRequestToken = 0; + // Feature 2: Track mtime at read time for conflict detection + let readMtimeMs = $state(0); + let showConflictDialog = $state(false); // Extension-based type detection const CODE_EXTS = new Set([ diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index b36ac88..2cfb595 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -15,6 +15,7 @@ export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thin export interface AgentMessage { id: string; + seqId: number; role: MsgRole; content: string; toolName?: string; @@ -123,6 +124,15 @@ const msgPersistTimers = new Map>(); const lastPersistedIndex = new Map(); // Fix #2 (Codex audit): Guard against double-start race const startingProjects = new Set(); +// Feature 1: Monotonic seqId counter per session for dedup on restore +const seqCounters = new Map(); + +function nextSeqId(sessionId: string): number { + const current = seqCounters.get(sessionId) ?? 0; + const next = current + 1; + seqCounters.set(sessionId, next); + return next; +} // ── Session persistence helpers ───────────────────────────────────────────── @@ -165,6 +175,7 @@ function persistMessages(session: AgentSession): void { toolName: m.toolName, toolInput: m.toolInput, timestamp: m.timestamp, + seqId: m.seqId, })); appRpc.request['session.messages.save']({ messages: msgs }).then(() => { lastPersistedIndex.set(session.sessionId, batchEnd); @@ -205,6 +216,10 @@ function ensureListeners() { } if (converted.length > 0) { + // Feature 1: Assign monotonic seqId to each message for dedup + for (const msg of converted) { + msg.seqId = nextSeqId(payload.sessionId); + } session.messages = [...session.messages, ...converted]; persistMessages(session); // Reset stall timer on activity @@ -323,6 +338,7 @@ function convertRawMessage(raw: { case 'text': return { id: raw.id, + seqId: 0, role: 'assistant', content: String(c?.text ?? ''), timestamp: raw.timestamp, @@ -331,6 +347,7 @@ function convertRawMessage(raw: { case 'thinking': return { id: raw.id, + seqId: 0, role: 'thinking', content: String(c?.text ?? ''), timestamp: raw.timestamp, @@ -342,6 +359,7 @@ function convertRawMessage(raw: { const path = extractToolPath(name, input); return { id: raw.id, + seqId: 0, role: 'tool-call', content: formatToolInput(name, input), toolName: name, @@ -358,6 +376,7 @@ function convertRawMessage(raw: { : JSON.stringify(output, null, 2); return { id: raw.id, + seqId: 0, role: 'tool-result', content: truncateOutput(text, 500), timestamp: raw.timestamp, @@ -374,6 +393,7 @@ function convertRawMessage(raw: { } return { id: raw.id, + seqId: 0, role: 'system', content: `Session initialized${model ? ` (${model})` : ''}`, timestamp: raw.timestamp, @@ -383,6 +403,7 @@ function convertRawMessage(raw: { case 'error': return { id: raw.id, + seqId: 0, role: 'system', content: `Error: ${String(c?.message ?? 'Unknown error')}`, timestamp: raw.timestamp, @@ -498,6 +519,7 @@ async function _startAgentInner( status: 'running', messages: [{ id: `${sessionId}-user-0`, + seqId: nextSeqId(sessionId), role: 'user', content: prompt, timestamp: Date.now(), @@ -558,6 +580,7 @@ export async function sendPrompt(projectId: string, prompt: string): Promise<{ o // Add user message immediately session.messages = [...session.messages, { id: `${sessionId}-user-${Date.now()}`, + seqId: nextSeqId(sessionId), role: 'user', content: prompt, timestamp: Date.now(), @@ -630,17 +653,31 @@ export async function loadLastSession(projectId: string): Promise { sessionId: session.sessionId, }); - const restoredMessages: AgentMessage[] = storedMsgs.map((m: { + // Feature 1: Deduplicate by seqId and resume counter from max + const seqIdSet = new Set(); + const restoredMessages: AgentMessage[] = []; + let maxSeqId = 0; + for (const m of storedMsgs as Array<{ msgId: string; role: string; content: string; toolName?: string; toolInput?: string; timestamp: number; - }) => ({ - id: m.msgId, - role: m.role as MsgRole, - content: m.content, - toolName: m.toolName, - toolInput: m.toolInput, - timestamp: m.timestamp, - })); + seqId?: number; + }>) { + const sid = m.seqId ?? 0; + if (sid > 0 && seqIdSet.has(sid)) continue; // deduplicate + if (sid > 0) seqIdSet.add(sid); + if (sid > maxSeqId) maxSeqId = sid; + restoredMessages.push({ + id: m.msgId, + seqId: sid, + role: m.role as MsgRole, + content: m.content, + toolName: m.toolName, + toolInput: m.toolInput, + timestamp: m.timestamp, + }); + } + // Resume seqId counter from max + if (maxSeqId > 0) seqCounters.set(session.sessionId, maxSeqId); sessions[session.sessionId] = { sessionId: session.sessionId, @@ -665,7 +702,30 @@ export async function loadLastSession(projectId: string): Promise { // ── Fix #14 (Codex audit): Session memory management ───────────────────────── -const MAX_SESSIONS_PER_PROJECT = 5; +// Feature 6: Configurable retention — defaults, overridable via settings +let retentionCount = 5; +let retentionDays = 30; + +/** Update retention settings (called from ProjectSettings). */ +export function setRetentionConfig(count: number, days: number): void { + retentionCount = Math.max(1, Math.min(50, count)); + retentionDays = Math.max(1, Math.min(365, days)); +} + +/** Load retention settings from backend on startup. */ +export async function loadRetentionConfig(): Promise { + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (settings['session_retention_count']) { + retentionCount = Math.max(1, parseInt(settings['session_retention_count'], 10) || 5); + } + if (settings['session_retention_days']) { + retentionDays = Math.max(1, parseInt(settings['session_retention_days'], 10) || 30); + } + } catch { /* use defaults */ } +} + +const MAX_SESSIONS_PER_PROJECT = 5; // legacy fallback /** * Purge a session entirely from the sessions map. @@ -674,6 +734,7 @@ const MAX_SESSIONS_PER_PROJECT = 5; export function purgeSession(sessionId: string): void { delete sessions[sessionId]; lastPersistedIndex.delete(sessionId); + seqCounters.delete(sessionId); clearStallTimer(sessionId); const pendingTimer = msgPersistTimers.get(sessionId); if (pendingTimer) { @@ -705,8 +766,10 @@ export function purgeProjectSessions(projectId: string): void { } } -/** Enforce max sessions per project — keep only the most recent N. */ +/** Enforce max sessions per project — keep only the most recent N + prune by age. */ function enforceMaxSessions(projectId: string): void { + const now = Date.now(); + const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000; const projectSessions = Object.entries(sessions) .filter(([, s]) => s.projectId === projectId && s.status !== 'running') .sort(([, a], [, b]) => { @@ -715,12 +778,21 @@ function enforceMaxSessions(projectId: string): void { return bTs - aTs; // newest first }); - if (projectSessions.length > MAX_SESSIONS_PER_PROJECT) { - const toRemove = projectSessions.slice(MAX_SESSIONS_PER_PROJECT); + // Feature 6: Prune by retention count + if (projectSessions.length > retentionCount) { + const toRemove = projectSessions.slice(retentionCount); for (const [sid] of toRemove) { purgeSession(sid); } } + + // Feature 6: Prune by age (retention days) + for (const [sid, s] of projectSessions) { + const lastTs = s.messages[s.messages.length - 1]?.timestamp ?? 0; + if (lastTs > 0 && (now - lastTs) > maxAgeMs) { + purgeSession(sid); + } + } } /** Initialize listeners on module load. */ diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index 5fab99a..da12961 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -128,7 +128,12 @@ export type PtyRPCRequests = { error?: string; }; }; - /** Write text content to a file. */ + /** Get file stat info (mtime, size) for conflict detection. */ + "files.stat": { + params: { path: string }; + response: { mtimeMs: number; size: number; error?: string }; + }; + /** Write text content to a file (atomic temp+rename). */ "files.write": { params: { path: string; content: string }; response: { ok: boolean; error?: string };