feat(health): add project health store, Mission Control bar, and session metrics

This commit is contained in:
Hibryda 2026-03-10 23:45:30 +01:00
parent 072316d63f
commit 42094eac2a
11 changed files with 773 additions and 16 deletions

View file

@ -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<Vec<session::SessionMetric>, 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,

View file

@ -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<String>,
pub status: String,
pub error_message: Option<String>,
}
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<Vec<SessionMetric>, 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::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(metrics)
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -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();
};
});
</script>

View file

@ -78,6 +78,31 @@ export async function loadProjectAgentState(projectId: string): Promise<ProjectA
return invoke('project_agent_state_load', { projectId });
}
// --- Session metrics ---
export interface SessionMetric {
id: number;
project_id: string;
session_id: string;
start_time: number;
end_time: number;
peak_tokens: number;
turn_count: number;
tool_call_count: number;
cost_usd: number;
model: string | null;
status: string;
error_message: string | null;
}
export async function saveSessionMetric(metric: Omit<SessionMetric, 'id'>): Promise<void> {
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
}
export async function loadSessionMetrics(projectId: string, limit = 20): Promise<SessionMetric[]> {
return invoke('session_metrics_load', { projectId, limit });
}
// --- CLI arguments ---
export async function getCliGroup(): Promise<string | null> {

View file

@ -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<string, string>();
// Map sessionId -> start timestamp for metrics
const sessionStartTimes = new Map<string, number>();
// In-flight persistence counter — prevents teardown from racing with async saves
let pendingPersistCount = 0;
@ -70,6 +75,7 @@ export async function startAgentDispatcher(): Promise<void> {
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<void> {
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();
}

View file

@ -1,16 +1,43 @@
<script lang="ts">
import { getAgentSessions } from '../../stores/agents.svelte';
import { getActiveGroup, getEnabledProjects, getActiveGroupId } from '../../stores/workspace.svelte';
import { getActiveGroup, getEnabledProjects, setActiveProject } from '../../stores/workspace.svelte';
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
let agentSessions = $derived(getAgentSessions());
let activeGroup = $derived(getActiveGroup());
let enabledProjects = $derived(getEnabledProjects());
let activeAgents = $derived(agentSessions.filter(s => s.status === 'running' || s.status === 'starting').length);
let totalCost = $derived(agentSessions.reduce((sum, s) => sum + s.costUsd, 0));
let totalTokens = $derived(agentSessions.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0));
let projectCount = $derived(enabledProjects.length);
let agentCount = $derived(agentSessions.length);
// Health-derived signals
let health = $derived(getHealthAggregates());
let attentionQueue = $derived(getAttentionQueue(5));
let showAttention = $state(false);
function projectName(projectId: string): string {
return enabledProjects.find(p => p.id === projectId)?.name ?? projectId.slice(0, 8);
}
function focusProject(projectId: string) {
setActiveProject(projectId);
showAttention = false;
}
function formatRate(rate: number): string {
if (rate < 0.01) return '$0/hr';
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
return `$${rate.toFixed(1)}/hr`;
}
function attentionColor(item: ProjectHealth): string {
if (item.attentionScore >= 90) return 'var(--ctp-red)';
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay1)';
}
</script>
<div class="status-bar">
@ -21,18 +48,49 @@
{/if}
<span class="item" title="Enabled projects">{projectCount} projects</span>
<span class="sep"></span>
<span class="item" title="Agent sessions">{agentCount} agents</span>
{#if activeAgents > 0}
<span class="sep"></span>
<span class="item active">
<!-- Agent states from health store -->
{#if health.running > 0}
<span class="item state-running" title="Running agents">
<span class="pulse"></span>
{activeAgents} running
{health.running} running
</span>
<span class="sep"></span>
{/if}
{#if health.idle > 0}
<span class="item state-idle" title="Idle agents">{health.idle} idle</span>
<span class="sep"></span>
{/if}
{#if health.stalled > 0}
<span class="item state-stalled" title="Stalled agents (>15 min inactive)">
{health.stalled} stalled
</span>
<span class="sep"></span>
{/if}
<!-- Attention queue toggle -->
{#if attentionQueue.length > 0}
<button
class="item attention-btn"
class:attention-open={showAttention}
onclick={() => showAttention = !showAttention}
title="Needs attention — click to expand"
>
<span class="attention-dot"></span>
{attentionQueue.length} need attention
</button>
{/if}
</div>
<div class="right">
{#if health.totalBurnRatePerHour > 0}
<span class="item burn-rate" title="Total burn rate across active sessions">
{formatRate(health.totalBurnRatePerHour)}
</span>
<span class="sep"></span>
{/if}
{#if totalTokens > 0}
<span class="item tokens">{totalTokens.toLocaleString()} tokens</span>
<span class="item tokens">{totalTokens.toLocaleString()} tok</span>
<span class="sep"></span>
{/if}
{#if totalCost > 0}
@ -43,6 +101,24 @@
</div>
</div>
<!-- Attention queue dropdown -->
{#if showAttention && attentionQueue.length > 0}
<div class="attention-panel">
{#each attentionQueue as item (item.projectId)}
<button
class="attention-card"
onclick={() => focusProject(item.projectId)}
>
<span class="card-name">{projectName(item.projectId)}</span>
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
{#if item.contextPressure !== null && item.contextPressure > 0.5}
<span class="card-ctx" title="Context usage">ctx {Math.round(item.contextPressure * 100)}%</span>
{/if}
</button>
{/each}
</div>
{/if}
<style>
.status-bar {
display: flex;
@ -57,6 +133,7 @@
font-family: 'JetBrains Mono', monospace;
user-select: none;
flex-shrink: 0;
position: relative;
}
.left, .right {
@ -82,15 +159,25 @@
font-weight: 600;
}
.active {
color: var(--ctp-blue);
/* Agent state indicators */
.state-running {
color: var(--ctp-green);
}
.state-idle {
color: var(--ctp-overlay1);
}
.state-stalled {
color: var(--ctp-peach);
font-weight: 600;
}
.pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-blue);
background: var(--ctp-green);
animation: pulse 1.5s ease-in-out infinite;
}
@ -99,7 +186,101 @@
50% { opacity: 0.3; }
}
/* Attention button */
.attention-btn {
background: none;
border: none;
color: var(--ctp-peach);
font: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0;
font-weight: 600;
}
.attention-btn:hover {
color: var(--ctp-red);
}
.attention-btn.attention-open {
color: var(--ctp-red);
}
.attention-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-peach);
animation: pulse 1.5s ease-in-out infinite;
}
.attention-btn.attention-open .attention-dot,
.attention-btn:hover .attention-dot {
background: var(--ctp-red);
}
/* Burn rate */
.burn-rate {
color: var(--ctp-mauve);
font-weight: 600;
}
.tokens { color: var(--ctp-overlay1); }
.cost { color: var(--ctp-yellow); }
.version { color: var(--ctp-overlay0); }
/* Attention panel dropdown */
.attention-panel {
position: absolute;
bottom: 1.5rem;
left: 0;
right: 0;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface1);
display: flex;
gap: 1px;
padding: 0.25rem 0.5rem;
z-index: 100;
overflow-x: auto;
}
.attention-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.attention-card:hover {
background: var(--ctp-surface0);
border-color: var(--ctp-surface2);
}
.card-name {
font-weight: 600;
color: var(--ctp-text);
}
.card-reason {
font-size: 0.625rem;
}
.card-ctx {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
padding: 0 0.25rem;
border-radius: 0.125rem;
}
</style>

View file

@ -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);
}
}

View file

@ -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<Record<string, boolean>>({});
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}
/>

View file

@ -1,15 +1,17 @@
<script lang="ts">
import type { ProjectConfig } from '../../types/groups';
import { PROJECT_ACCENTS } from '../../types/groups';
import type { ProjectHealth } from '../../stores/health.svelte';
interface Props {
project: ProjectConfig;
slotIndex: number;
active: boolean;
health: ProjectHealth | null;
onclick: () => void;
}
let { project, slotIndex, active, onclick }: Props = $props();
let { project, slotIndex, active, health, onclick }: Props = $props();
let accentVar = $derived(PROJECT_ACCENTS[slotIndex % PROJECT_ACCENTS.length]);
@ -25,6 +27,44 @@
}
return cwd;
});
let statusDotClass = $derived(() => {
if (!health) return 'dot-inactive';
switch (health.activityState) {
case 'running': return 'dot-running';
case 'idle': return 'dot-idle';
case 'stalled': return 'dot-stalled';
default: return 'dot-inactive';
}
});
let statusTooltip = $derived(() => {
if (!health) return 'No active session';
switch (health.activityState) {
case 'running': return health.activeTool ? `Running: ${health.activeTool}` : 'Running';
case 'idle': {
const secs = Math.floor(health.idleDurationMs / 1000);
return secs < 60 ? `Idle (${secs}s)` : `Idle (${Math.floor(secs / 60)}m ${secs % 60}s)`;
}
case 'stalled': {
const mins = Math.floor(health.idleDurationMs / 60_000);
return `Stalled — ${mins} min since last activity`;
}
default: return 'Inactive';
}
});
let contextPct = $derived(health?.contextPressure !== null && health?.contextPressure !== undefined
? Math.round(health.contextPressure * 100)
: null);
let ctxColor = $derived(() => {
if (contextPct === null) return '';
if (contextPct > 90) return 'var(--ctp-red)';
if (contextPct > 75) return 'var(--ctp-peach)';
if (contextPct > 50) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay0)';
});
</script>
<button
@ -34,11 +74,22 @@
{onclick}
>
<div class="header-main">
<span class="status-dot {statusDotClass()}" title={statusTooltip()}></span>
<span class="project-icon">{project.icon || '📁'}</span>
<span class="project-name">{project.name}</span>
<span class="project-id">({project.identifier})</span>
</div>
<div class="header-info">
{#if contextPct !== null && contextPct > 0}
<span class="info-ctx" style="color: {ctxColor()}" title="Context window usage">ctx {contextPct}%</span>
<span class="info-sep">·</span>
{/if}
{#if health && health.burnRatePerHour > 0.01}
<span class="info-rate" title="Burn rate">
${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr
</span>
<span class="info-sep">·</span>
{/if}
<span class="info-cwd" title={project.cwd}>{displayCwd()}</span>
{#if project.profile}
<span class="info-sep">·</span>
@ -83,6 +134,37 @@
flex-shrink: 0;
}
/* Status dot */
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-inactive {
background: var(--ctp-surface2);
}
.dot-running {
background: var(--ctp-green);
animation: pulse 1.5s ease-in-out infinite;
}
.dot-idle {
background: var(--ctp-overlay0);
}
.dot-stalled {
background: var(--ctp-peach);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.project-icon {
font-size: 0.85rem;
line-height: 1;
@ -109,6 +191,20 @@
overflow: hidden;
}
.info-ctx {
font-size: 0.6rem;
font-weight: 600;
font-family: var(--font-mono, monospace);
white-space: nowrap;
}
.info-rate {
font-size: 0.6rem;
color: var(--ctp-mauve);
font-family: var(--font-mono, monospace);
white-space: nowrap;
}
.info-cwd {
font-size: 0.65rem;
color: var(--ctp-overlay0);

View file

@ -0,0 +1,291 @@
// Project health tracking — Svelte 5 runes
// Tracks per-project activity state, burn rate, context pressure, and attention scoring
import { getAgentSession, type AgentSession } from './agents.svelte';
// --- Types ---
export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
export interface ProjectHealth {
projectId: string;
sessionId: string | 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;
/** 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 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;
// Attention score weights (higher = more urgent)
const SCORE_STALLED = 100;
const SCORE_CONTEXT_CRITICAL = 80; // >90% context
const SCORE_CONTEXT_HIGH = 40; // >75% context
const SCORE_ERROR = 90;
const SCORE_IDLE_LONG = 20; // >2x stall threshold but not stalled (shouldn't happen, safety)
// --- State ---
interface ProjectTracker {
projectId: string;
sessionId: string | 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]>;
}
let trackers = $state<Map<string, ProjectTracker>>(new Map());
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: string, sessionId: string | 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: [],
});
}
/** Remove a project from health tracking */
export function untrackProject(projectId: string): void {
trackers.delete(projectId);
}
/** Update session ID for a tracked project */
export function updateProjectSession(projectId: string, sessionId: string): void {
const t = trackers.get(projectId);
if (t) {
t.sessionId = sessionId;
}
}
/** Record activity — call on every agent message */
export function recordActivity(projectId: string, toolName?: string): void {
const t = trackers.get(projectId);
if (!t) return;
t.lastActivityTs = Date.now();
if (toolName !== undefined) {
t.lastToolName = toolName;
t.toolInFlight = true;
}
}
/** Record tool completion */
export function recordToolDone(projectId: string): 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: string, 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);
}
/** Start the health tick timer */
export function startHealthTick(): void {
if (tickInterval) return;
tickInterval = setInterval(() => {
tickTs = Date.now();
}, TICK_INTERVAL_MS);
}
/** Stop the health tick timer */
export function stopHealthTick(): void {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}
/** Clear all tracked projects */
export function clearHealthTracking(): void {
trackers = 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;
if (idleDurationMs >= STALL_THRESHOLD_MS) {
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);
// Attention scoring
let attentionScore = 0;
let attentionReason: string | null = null;
if (session?.status === 'error') {
attentionScore = SCORE_ERROR;
attentionReason = `Error: ${session.error?.slice(0, 60) ?? 'Unknown'}`;
} else if (activityState === 'stalled') {
attentionScore = SCORE_STALLED;
const mins = Math.floor(idleDurationMs / 60_000);
attentionReason = `Stalled — ${mins} min since last activity`;
} else if (contextPressure !== null && contextPressure > 0.9) {
attentionScore = SCORE_CONTEXT_CRITICAL;
attentionReason = `Context ${Math.round(contextPressure * 100)}% — near limit`;
} else if (contextPressure !== null && contextPressure > 0.75) {
attentionScore = SCORE_CONTEXT_HIGH;
attentionReason = `Context ${Math.round(contextPressure * 100)}%`;
}
return {
projectId: tracker.projectId,
sessionId: tracker.sessionId,
activityState,
activeTool,
idleDurationMs,
burnRatePerHour,
contextPressure,
attentionScore,
attentionReason,
};
}
/** Get health for a single project (reactive via tickTs) */
export function getProjectHealth(projectId: string): 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 };
}

View file

@ -1,6 +1,7 @@
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups';
import { clearAllAgentSessions } from '../stores/agents.svelte';
import { clearHealthTracking } from '../stores/health.svelte';
import { waitForPendingPersistence } from '../agent-dispatcher';
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings';
@ -73,9 +74,10 @@ export async function switchGroup(groupId: string): Promise<void> {
// Wait for any in-flight persistence before clearing state
await waitForPendingPersistence();
// Teardown: clear terminal tabs and agent sessions for the old group
// Teardown: clear terminal tabs, agent sessions, and health tracking for the old group
projectTerminals = {};
clearAllAgentSessions();
clearHealthTracking();
activeGroupId = groupId;
activeProjectId = null;