diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index b3989f5..b4417d5 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -448,6 +448,25 @@ fn project_agent_state_load( state.session_db.load_project_agent_state(&project_id) } +// --- Session metrics commands --- + +#[tauri::command] +fn session_metric_save( + state: State<'_, AppState>, + metric: session::SessionMetric, +) -> Result<(), String> { + state.session_db.save_session_metric(&metric) +} + +#[tauri::command] +fn session_metrics_load( + state: State<'_, AppState>, + project_id: String, + limit: i64, +) -> Result, String> { + state.session_db.load_session_metrics(&project_id, limit) +} + // --- File browser commands (Files tab) --- #[derive(serde::Serialize)] @@ -761,6 +780,8 @@ pub fn run() { agent_messages_load, project_agent_state_save, project_agent_state_load, + session_metric_save, + session_metrics_load, cli_get_group, pick_directory, open_url, diff --git a/v2/src-tauri/src/session.rs b/v2/src-tauri/src/session.rs index 3a61abb..a87a9fd 100644 --- a/v2/src-tauri/src/session.rs +++ b/v2/src-tauri/src/session.rs @@ -154,7 +154,24 @@ impl SessionDb { output_tokens INTEGER DEFAULT 0, last_prompt TEXT, updated_at INTEGER NOT NULL - );" + ); + + CREATE TABLE IF NOT EXISTS session_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + session_id TEXT NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER NOT NULL, + peak_tokens INTEGER DEFAULT 0, + turn_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0, + model TEXT, + status TEXT NOT NULL, + error_message TEXT + ); + CREATE INDEX IF NOT EXISTS idx_session_metrics_project + ON session_metrics(project_id);" ).map_err(|e| format!("Migration (v3 tables) failed: {e}"))?; Ok(()) @@ -507,6 +524,81 @@ pub struct ProjectAgentState { pub updated_at: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMetric { + #[serde(default)] + pub id: i64, + pub project_id: String, + pub session_id: String, + pub start_time: i64, + pub end_time: i64, + pub peak_tokens: i64, + pub turn_count: i64, + pub tool_call_count: i64, + pub cost_usd: f64, + pub model: Option, + pub status: String, + pub error_message: Option, +} + +impl SessionDb { + pub fn save_session_metric(&self, metric: &SessionMetric) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO session_metrics (project_id, session_id, start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + metric.project_id, + metric.session_id, + metric.start_time, + metric.end_time, + metric.peak_tokens, + metric.turn_count, + metric.tool_call_count, + metric.cost_usd, + metric.model, + metric.status, + metric.error_message, + ], + ).map_err(|e| format!("Save session metric failed: {e}"))?; + + // Enforce retention: keep last 100 per project + conn.execute( + "DELETE FROM session_metrics WHERE project_id = ?1 AND id NOT IN (SELECT id FROM session_metrics WHERE project_id = ?1 ORDER BY end_time DESC LIMIT 100)", + params![metric.project_id], + ).map_err(|e| format!("Prune session metrics failed: {e}"))?; + + Ok(()) + } + + pub fn load_session_metrics(&self, project_id: &str, limit: i64) -> Result, String> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, project_id, session_id, start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message FROM session_metrics WHERE project_id = ?1 ORDER BY end_time DESC LIMIT ?2" + ).map_err(|e| format!("Query prepare failed: {e}"))?; + + let metrics = stmt.query_map(params![project_id, limit], |row| { + Ok(SessionMetric { + id: row.get(0)?, + project_id: row.get(1)?, + session_id: row.get(2)?, + start_time: row.get(3)?, + end_time: row.get(4)?, + peak_tokens: row.get(5)?, + turn_count: row.get(6)?, + tool_call_count: row.get(7)?, + cost_usd: row.get(8)?, + model: row.get(9)?, + status: row.get(10)?, + error_message: row.get(11)?, + }) + }).map_err(|e| format!("Query failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("Row read failed: {e}"))?; + + Ok(metrics) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/v2/src/App.svelte b/v2/src/App.svelte index 3984613..11d6352 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -4,6 +4,7 @@ import { getSetting } from './lib/adapters/settings-bridge'; import { isDetachedMode, getDetachedConfig } from './lib/utils/detach'; import { startAgentDispatcher, stopAgentDispatcher } from './lib/agent-dispatcher'; + import { startHealthTick, stopHealthTick, clearHealthTracking } from './lib/stores/health.svelte'; import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte'; // Workspace components @@ -65,6 +66,7 @@ if (v) document.documentElement.style.setProperty('--project-max-aspect', v); }); startAgentDispatcher(); + startHealthTick(); if (!detached) { loadWorkspace().then(() => { loaded = true; }); @@ -120,6 +122,7 @@ return () => { window.removeEventListener('keydown', handleKeydown); stopAgentDispatcher(); + stopHealthTick(); }; }); diff --git a/v2/src/lib/adapters/groups-bridge.ts b/v2/src/lib/adapters/groups-bridge.ts index 9e35e1f..1782563 100644 --- a/v2/src/lib/adapters/groups-bridge.ts +++ b/v2/src/lib/adapters/groups-bridge.ts @@ -78,6 +78,31 @@ export async function loadProjectAgentState(projectId: string): Promise): Promise { + return invoke('session_metric_save', { metric: { id: 0, ...metric } }); +} + +export async function loadSessionMetrics(projectId: string, limit = 20): Promise { + return invoke('session_metrics_load', { projectId, limit }); +} + // --- CLI arguments --- export async function getCliGroup(): Promise { diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 3861ba7..749b45e 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -20,9 +20,11 @@ import { notify } from './stores/notifications.svelte'; import { saveProjectAgentState, saveAgentMessages, + saveSessionMetric, type AgentMessageRecord, } from './adapters/groups-bridge'; import { tel } from './adapters/telemetry-bridge'; +import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; let unlistenMsg: (() => void) | null = null; let unlistenExit: (() => void) | null = null; @@ -30,6 +32,9 @@ let unlistenExit: (() => void) | null = null; // Map sessionId -> projectId for persistence routing const sessionProjectMap = new Map(); +// Map sessionId -> start timestamp for metrics +const sessionStartTimes = new Map(); + // In-flight persistence counter — prevents teardown from racing with async saves let pendingPersistCount = 0; @@ -70,6 +75,7 @@ export async function startAgentDispatcher(): Promise { switch (msg.type) { case 'agent_started': updateAgentStatus(sessionId, 'running'); + sessionStartTimes.set(sessionId, Date.now()); tel.info('agent_started', { sessionId }); break; @@ -172,6 +178,9 @@ function handleAgentEvent(sessionId: string, event: Record): vo if (SUBAGENT_TOOL_NAMES.has(tc.name)) { spawnSubagentPane(sessionId, tc); } + // Health: record tool start + const projId = sessionProjectMap.get(sessionId); + if (projId) recordActivity(projId, tc.name); break; } @@ -200,6 +209,12 @@ function handleAgentEvent(sessionId: string, event: Record): vo updateAgentStatus(sessionId, 'done'); notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`); } + // Health: record token snapshot + tool done + const costProjId = sessionProjectMap.get(sessionId); + if (costProjId) { + recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd); + recordToolDone(costProjId); + } // Persist session state for project-scoped sessions persistSessionForProject(sessionId); break; @@ -207,7 +222,14 @@ function handleAgentEvent(sessionId: string, event: Record): vo } } + // Health: record general activity for non-tool messages (text, thinking) if (mainMessages.length > 0) { + const actProjId = sessionProjectMap.get(sessionId); + if (actProjId) { + const hasToolResult = mainMessages.some(m => m.type === 'tool_result'); + if (hasToolResult) recordToolDone(actProjId); + else recordActivity(actProjId); + } appendAgentMessages(sessionId, mainMessages); } @@ -322,6 +344,23 @@ async function persistSessionForProject(sessionId: string): Promise { if (records.length > 0) { await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records); } + + // Persist session metric for historical tracking + const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length; + const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000); + await saveSessionMetric({ + project_id: projectId, + session_id: sessionId, + start_time: Math.floor(startTime / 1000), + end_time: nowSecs, + peak_tokens: session.inputTokens + session.outputTokens, + turn_count: session.numTurns, + tool_call_count: toolCallCount, + cost_usd: session.costUsd, + model: session.model ?? null, + status: session.status, + error_message: session.error ?? null, + }); } catch (e) { console.warn('Failed to persist agent session:', e); } finally { @@ -341,4 +380,5 @@ export function stopAgentDispatcher(): void { // Clear routing maps to prevent unbounded memory growth toolUseToChildPane.clear(); sessionProjectMap.clear(); + sessionStartTimes.clear(); } diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte index f8b3e68..5074e7a 100644 --- a/v2/src/lib/components/StatusBar/StatusBar.svelte +++ b/v2/src/lib/components/StatusBar/StatusBar.svelte @@ -1,16 +1,43 @@
@@ -21,18 +48,49 @@ {/if} {projectCount} projects - {agentCount} agents - {#if activeAgents > 0} - - + + + {#if health.running > 0} + - {activeAgents} running + {health.running} running + + {/if} + {#if health.idle > 0} + {health.idle} idle + + {/if} + {#if health.stalled > 0} + + {health.stalled} stalled + + + {/if} + + + {#if attentionQueue.length > 0} + {/if}
+
+ {#if health.totalBurnRatePerHour > 0} + + {formatRate(health.totalBurnRatePerHour)} + + + {/if} {#if totalTokens > 0} - {totalTokens.toLocaleString()} tokens + {totalTokens.toLocaleString()} tok {/if} {#if totalCost > 0} @@ -43,6 +101,24 @@
+ +{#if showAttention && attentionQueue.length > 0} +
+ {#each attentionQueue as item (item.projectId)} + + {/each} +
+{/if} + diff --git a/v2/src/lib/components/Workspace/ClaudeSession.svelte b/v2/src/lib/components/Workspace/ClaudeSession.svelte index 831f1d7..c52c7d4 100644 --- a/v2/src/lib/components/Workspace/ClaudeSession.svelte +++ b/v2/src/lib/components/Workspace/ClaudeSession.svelte @@ -7,6 +7,7 @@ type AgentMessageRecord, } from '../../adapters/groups-bridge'; import { registerSessionProject } from '../../agent-dispatcher'; + import { trackProject, updateProjectSession } from '../../stores/health.svelte'; import { createAgentSession, appendAgentMessages, @@ -35,6 +36,7 @@ hasRestoredHistory = false; lastState = null; registerSessionProject(sessionId, project.id); + trackProject(project.id, sessionId); onsessionid?.(sessionId); } @@ -67,8 +69,9 @@ sessionId = crypto.randomUUID(); } finally { loading = false; - // Register session -> project mapping for persistence + // Register session -> project mapping for persistence + health tracking registerSessionProject(sessionId, project.id); + trackProject(project.id, sessionId); onsessionid?.(sessionId); } } diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte index 4a72a64..2002cb5 100644 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ b/v2/src/lib/components/Workspace/ProjectBox.svelte @@ -11,6 +11,7 @@ import SshTab from './SshTab.svelte'; import MemoriesTab from './MemoriesTab.svelte'; import { getTerminalTabs } from '../../stores/workspace.svelte'; + import { getProjectHealth } from '../../stores/health.svelte'; interface Props { project: ProjectConfig; @@ -32,6 +33,7 @@ let everActivated = $state>({}); let termTabs = $derived(getTerminalTabs(project.id)); + let projectHealth = $derived(getProjectHealth(project.id)); let termTabCount = $derived(termTabs.length); /** Activate a tab — for lazy tabs, mark as ever-activated */ @@ -56,6 +58,7 @@ {project} {slotIndex} {active} + health={projectHealth} onclick={onactivate} /> diff --git a/v2/src/lib/components/Workspace/ProjectHeader.svelte b/v2/src/lib/components/Workspace/ProjectHeader.svelte index 06471cc..56113a5 100644 --- a/v2/src/lib/components/Workspace/ProjectHeader.svelte +++ b/v2/src/lib/components/Workspace/ProjectHeader.svelte @@ -1,15 +1,17 @@