feat: @agor/stores package + Electrobun hardening (WIP)
- packages/stores/: theme, notifications, health stores extracted - Electrobun hardening: durable event sequencing, file conflict detection, push-based updates, backpressure guards (partial, agents still running)
This commit is contained in:
parent
5836fb7d80
commit
5e1fd62ed9
13 changed files with 855 additions and 665 deletions
329
packages/stores/health.svelte.ts
Normal file
329
packages/stores/health.svelte.ts
Normal file
|
|
@ -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<string, number> = {
|
||||||
|
'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<Map<ProjectIdType, ProjectTracker>>(new Map());
|
||||||
|
let stallThresholds = $state<Map<ProjectIdType, number>>(new Map()); // projectId → ms
|
||||||
|
let tickTs = $state<number>(Date.now());
|
||||||
|
let tickInterval: ReturnType<typeof setInterval> | 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 };
|
||||||
|
}
|
||||||
5
packages/stores/index.ts
Normal file
5
packages/stores/index.ts
Normal file
|
|
@ -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';
|
||||||
170
packages/stores/notifications.svelte.ts
Normal file
170
packages/stores/notifications.svelte.ts
Normal file
|
|
@ -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<Toast[]>([]);
|
||||||
|
let notificationHistory = $state<HistoryNotification[]>([]);
|
||||||
|
|
||||||
|
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<ToastType, number[]>();
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
}
|
||||||
13
packages/stores/package.json
Normal file
13
packages/stores/package.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
151
packages/stores/theme.svelte.ts
Normal file
151
packages/stores/theme.svelte.ts
Normal file
|
|
@ -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<ThemeId>('mocha');
|
||||||
|
let customPalette = $state<ThemePalette | null>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
return setTheme(flavor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load saved theme from settings DB and apply. Call once on app startup. */
|
||||||
|
export async function initTheme(): Promise<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/stores/tsconfig.json
Normal file
23
packages/stores/tsconfig.json
Normal file
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,329 +1,21 @@
|
||||||
// Project health tracking — Svelte 5 runes
|
// Re-export from @agor/stores package
|
||||||
// Tracks per-project activity state, burn rate, context pressure, and attention scoring
|
export {
|
||||||
|
type ActivityState,
|
||||||
import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids';
|
type ProjectHealth,
|
||||||
import { getAgentSession, type AgentSession } from './agents.svelte';
|
type AttentionItem,
|
||||||
import { getProjectConflicts } from './conflicts.svelte';
|
trackProject,
|
||||||
import { scoreAttention } from '../utils/attention-scorer';
|
untrackProject,
|
||||||
|
setStallThreshold,
|
||||||
// --- Types ---
|
updateProjectSession,
|
||||||
|
recordActivity,
|
||||||
export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
|
recordToolDone,
|
||||||
|
recordTokenSnapshot,
|
||||||
export interface ProjectHealth {
|
startHealthTick,
|
||||||
projectId: ProjectIdType;
|
stopHealthTick,
|
||||||
sessionId: SessionIdType | null;
|
setReviewQueueDepth,
|
||||||
/** Current activity state */
|
clearHealthTracking,
|
||||||
activityState: ActivityState;
|
getProjectHealth,
|
||||||
/** Name of currently running tool (if any) */
|
getAllProjectHealth,
|
||||||
activeTool: string | null;
|
getAttentionQueue,
|
||||||
/** Duration in ms since last activity (0 if running a tool) */
|
getHealthAggregates,
|
||||||
idleDurationMs: number;
|
} from '@agor/stores/health.svelte';
|
||||||
/** 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<string, number> = {
|
|
||||||
'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<Map<ProjectIdType, ProjectTracker>>(new Map());
|
|
||||||
let stallThresholds = $state<Map<ProjectIdType, number>>(new Map()); // projectId → ms
|
|
||||||
let tickTs = $state<number>(Date.now());
|
|
||||||
let tickInterval: ReturnType<typeof setInterval> | 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 };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,170 +1,16 @@
|
||||||
// Notification store — ephemeral toasts + persistent notification history
|
// Re-export from @agor/stores package
|
||||||
|
export {
|
||||||
import { getBackend } from '../backend/backend';
|
type ToastType,
|
||||||
|
type Toast,
|
||||||
// --- Toast types (existing) ---
|
type NotificationType,
|
||||||
|
type HistoryNotification,
|
||||||
export type ToastType = 'info' | 'success' | 'warning' | 'error';
|
getNotifications,
|
||||||
|
notify,
|
||||||
export interface Toast {
|
dismissNotification,
|
||||||
id: string;
|
addNotification,
|
||||||
type: ToastType;
|
getNotificationHistory,
|
||||||
message: string;
|
getUnreadCount,
|
||||||
timestamp: number;
|
markRead,
|
||||||
}
|
markAllRead,
|
||||||
|
clearHistory,
|
||||||
// --- Notification history types (new) ---
|
} from '@agor/stores/notifications.svelte';
|
||||||
|
|
||||||
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<Toast[]>([]);
|
|
||||||
let notificationHistory = $state<HistoryNotification[]>([]);
|
|
||||||
|
|
||||||
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<ToastType, number[]>();
|
|
||||||
|
|
||||||
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 = [];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,151 +1,14 @@
|
||||||
// Theme store — persists theme selection via settings bridge
|
// Re-export from @agor/stores package
|
||||||
|
export {
|
||||||
import { getSetting, setSetting } from './settings-store.svelte';
|
onThemeChange,
|
||||||
import { handleInfraError } from '../utils/handle-error';
|
getCurrentTheme,
|
||||||
import {
|
getCurrentFlavor,
|
||||||
type ThemeId,
|
getXtermTheme,
|
||||||
type ThemePalette,
|
previewPalette,
|
||||||
type CatppuccinFlavor,
|
clearPreview,
|
||||||
ALL_THEME_IDS,
|
setCustomTheme,
|
||||||
buildXtermTheme,
|
isCustomThemeActive,
|
||||||
buildXtermThemeFromPalette,
|
setTheme,
|
||||||
applyCssVariables,
|
setFlavor,
|
||||||
applyPaletteDirect,
|
initTheme,
|
||||||
type XtermTheme,
|
} from '@agor/stores/theme.svelte';
|
||||||
} from '../styles/themes';
|
|
||||||
|
|
||||||
let currentTheme = $state<ThemeId>('mocha');
|
|
||||||
let customPalette = $state<ThemePalette | null>(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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
return setTheme(flavor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load saved theme from settings DB and apply. Call once on app startup. */
|
|
||||||
export async function initTheme(): Promise<void> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
"files.write": async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||||
const guard = guardPath(filePath);
|
const guard = guardPath(filePath);
|
||||||
if (!guard.valid) {
|
if (!guard.valid) {
|
||||||
|
|
@ -83,7 +98,10 @@ export function createFilesHandlers() {
|
||||||
return { ok: false, error: guard.error };
|
return { ok: false, error: guard.error };
|
||||||
}
|
}
|
||||||
try {
|
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 };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@
|
||||||
let editorContent = $state('');
|
let editorContent = $state('');
|
||||||
// Fix #6: Request token to discard stale file load responses
|
// Fix #6: Request token to discard stale file load responses
|
||||||
let fileRequestToken = 0;
|
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
|
// Extension-based type detection
|
||||||
const CODE_EXTS = new Set([
|
const CODE_EXTS = new Set([
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thin
|
||||||
|
|
||||||
export interface AgentMessage {
|
export interface AgentMessage {
|
||||||
id: string;
|
id: string;
|
||||||
|
seqId: number;
|
||||||
role: MsgRole;
|
role: MsgRole;
|
||||||
content: string;
|
content: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
|
|
@ -123,6 +124,15 @@ const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const lastPersistedIndex = new Map<string, number>();
|
const lastPersistedIndex = new Map<string, number>();
|
||||||
// Fix #2 (Codex audit): Guard against double-start race
|
// Fix #2 (Codex audit): Guard against double-start race
|
||||||
const startingProjects = new Set<string>();
|
const startingProjects = new Set<string>();
|
||||||
|
// Feature 1: Monotonic seqId counter per session for dedup on restore
|
||||||
|
const seqCounters = new Map<string, number>();
|
||||||
|
|
||||||
|
function nextSeqId(sessionId: string): number {
|
||||||
|
const current = seqCounters.get(sessionId) ?? 0;
|
||||||
|
const next = current + 1;
|
||||||
|
seqCounters.set(sessionId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Session persistence helpers ─────────────────────────────────────────────
|
// ── Session persistence helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -165,6 +175,7 @@ function persistMessages(session: AgentSession): void {
|
||||||
toolName: m.toolName,
|
toolName: m.toolName,
|
||||||
toolInput: m.toolInput,
|
toolInput: m.toolInput,
|
||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
|
seqId: m.seqId,
|
||||||
}));
|
}));
|
||||||
appRpc.request['session.messages.save']({ messages: msgs }).then(() => {
|
appRpc.request['session.messages.save']({ messages: msgs }).then(() => {
|
||||||
lastPersistedIndex.set(session.sessionId, batchEnd);
|
lastPersistedIndex.set(session.sessionId, batchEnd);
|
||||||
|
|
@ -205,6 +216,10 @@ function ensureListeners() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (converted.length > 0) {
|
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];
|
session.messages = [...session.messages, ...converted];
|
||||||
persistMessages(session);
|
persistMessages(session);
|
||||||
// Reset stall timer on activity
|
// Reset stall timer on activity
|
||||||
|
|
@ -323,6 +338,7 @@ function convertRawMessage(raw: {
|
||||||
case 'text':
|
case 'text':
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
seqId: 0,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: String(c?.text ?? ''),
|
content: String(c?.text ?? ''),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
|
|
@ -331,6 +347,7 @@ function convertRawMessage(raw: {
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
seqId: 0,
|
||||||
role: 'thinking',
|
role: 'thinking',
|
||||||
content: String(c?.text ?? ''),
|
content: String(c?.text ?? ''),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
|
|
@ -342,6 +359,7 @@ function convertRawMessage(raw: {
|
||||||
const path = extractToolPath(name, input);
|
const path = extractToolPath(name, input);
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
seqId: 0,
|
||||||
role: 'tool-call',
|
role: 'tool-call',
|
||||||
content: formatToolInput(name, input),
|
content: formatToolInput(name, input),
|
||||||
toolName: name,
|
toolName: name,
|
||||||
|
|
@ -358,6 +376,7 @@ function convertRawMessage(raw: {
|
||||||
: JSON.stringify(output, null, 2);
|
: JSON.stringify(output, null, 2);
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
seqId: 0,
|
||||||
role: 'tool-result',
|
role: 'tool-result',
|
||||||
content: truncateOutput(text, 500),
|
content: truncateOutput(text, 500),
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
|
|
@ -374,6 +393,7 @@ function convertRawMessage(raw: {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
seqId: 0,
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Session initialized${model ? ` (${model})` : ''}`,
|
content: `Session initialized${model ? ` (${model})` : ''}`,
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
|
|
@ -383,6 +403,7 @@ function convertRawMessage(raw: {
|
||||||
case 'error':
|
case 'error':
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
|
seqId: 0,
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `Error: ${String(c?.message ?? 'Unknown error')}`,
|
content: `Error: ${String(c?.message ?? 'Unknown error')}`,
|
||||||
timestamp: raw.timestamp,
|
timestamp: raw.timestamp,
|
||||||
|
|
@ -498,6 +519,7 @@ async function _startAgentInner(
|
||||||
status: 'running',
|
status: 'running',
|
||||||
messages: [{
|
messages: [{
|
||||||
id: `${sessionId}-user-0`,
|
id: `${sessionId}-user-0`,
|
||||||
|
seqId: nextSeqId(sessionId),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: prompt,
|
content: prompt,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -558,6 +580,7 @@ export async function sendPrompt(projectId: string, prompt: string): Promise<{ o
|
||||||
// Add user message immediately
|
// Add user message immediately
|
||||||
session.messages = [...session.messages, {
|
session.messages = [...session.messages, {
|
||||||
id: `${sessionId}-user-${Date.now()}`,
|
id: `${sessionId}-user-${Date.now()}`,
|
||||||
|
seqId: nextSeqId(sessionId),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: prompt,
|
content: prompt,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -630,17 +653,31 @@ export async function loadLastSession(projectId: string): Promise<boolean> {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoredMessages: AgentMessage[] = storedMsgs.map((m: {
|
// Feature 1: Deduplicate by seqId and resume counter from max
|
||||||
|
const seqIdSet = new Set<number>();
|
||||||
|
const restoredMessages: AgentMessage[] = [];
|
||||||
|
let maxSeqId = 0;
|
||||||
|
for (const m of storedMsgs as Array<{
|
||||||
msgId: string; role: string; content: string;
|
msgId: string; role: string; content: string;
|
||||||
toolName?: string; toolInput?: string; timestamp: number;
|
toolName?: string; toolInput?: string; timestamp: number;
|
||||||
}) => ({
|
seqId?: number;
|
||||||
id: m.msgId,
|
}>) {
|
||||||
role: m.role as MsgRole,
|
const sid = m.seqId ?? 0;
|
||||||
content: m.content,
|
if (sid > 0 && seqIdSet.has(sid)) continue; // deduplicate
|
||||||
toolName: m.toolName,
|
if (sid > 0) seqIdSet.add(sid);
|
||||||
toolInput: m.toolInput,
|
if (sid > maxSeqId) maxSeqId = sid;
|
||||||
timestamp: m.timestamp,
|
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] = {
|
sessions[session.sessionId] = {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
|
@ -665,7 +702,30 @@ export async function loadLastSession(projectId: string): Promise<boolean> {
|
||||||
|
|
||||||
// ── Fix #14 (Codex audit): Session memory management ─────────────────────────
|
// ── 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<void> {
|
||||||
|
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.
|
* Purge a session entirely from the sessions map.
|
||||||
|
|
@ -674,6 +734,7 @@ const MAX_SESSIONS_PER_PROJECT = 5;
|
||||||
export function purgeSession(sessionId: string): void {
|
export function purgeSession(sessionId: string): void {
|
||||||
delete sessions[sessionId];
|
delete sessions[sessionId];
|
||||||
lastPersistedIndex.delete(sessionId);
|
lastPersistedIndex.delete(sessionId);
|
||||||
|
seqCounters.delete(sessionId);
|
||||||
clearStallTimer(sessionId);
|
clearStallTimer(sessionId);
|
||||||
const pendingTimer = msgPersistTimers.get(sessionId);
|
const pendingTimer = msgPersistTimers.get(sessionId);
|
||||||
if (pendingTimer) {
|
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 {
|
function enforceMaxSessions(projectId: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
|
||||||
const projectSessions = Object.entries(sessions)
|
const projectSessions = Object.entries(sessions)
|
||||||
.filter(([, s]) => s.projectId === projectId && s.status !== 'running')
|
.filter(([, s]) => s.projectId === projectId && s.status !== 'running')
|
||||||
.sort(([, a], [, b]) => {
|
.sort(([, a], [, b]) => {
|
||||||
|
|
@ -715,12 +778,21 @@ function enforceMaxSessions(projectId: string): void {
|
||||||
return bTs - aTs; // newest first
|
return bTs - aTs; // newest first
|
||||||
});
|
});
|
||||||
|
|
||||||
if (projectSessions.length > MAX_SESSIONS_PER_PROJECT) {
|
// Feature 6: Prune by retention count
|
||||||
const toRemove = projectSessions.slice(MAX_SESSIONS_PER_PROJECT);
|
if (projectSessions.length > retentionCount) {
|
||||||
|
const toRemove = projectSessions.slice(retentionCount);
|
||||||
for (const [sid] of toRemove) {
|
for (const [sid] of toRemove) {
|
||||||
purgeSession(sid);
|
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. */
|
/** Initialize listeners on module load. */
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,12 @@ export type PtyRPCRequests = {
|
||||||
error?: string;
|
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": {
|
"files.write": {
|
||||||
params: { path: string; content: string };
|
params: { path: string; content: string };
|
||||||
response: { ok: boolean; error?: string };
|
response: { ok: boolean; error?: string };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue