From ca890721ad600bb4dc1675d3c89b407aa272cd66 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 01:41:28 -0500
Subject: [PATCH 01/59] MAESTRO: feat: add account multiplexing shared types
and interfaces
Add foundational type definitions for the account multiplexing system:
- AccountProfile, AccountUsageSnapshot, AccountAssignment for core data models
- AccountSwitchConfig and AccountSwitchEvent for auto-switching behavior
- AccountCapacityMetrics for capacity planning
- AccountId, AccountStatus, AccountAuthMethod, MultiplexableAgent type aliases
- ACCOUNT_SWITCH_DEFAULTS and DEFAULT_TOKEN_WINDOW_MS constants
- accountId and accountName optional fields on Session interface
Co-Authored-By: Claude Opus 4.6
---
src/renderer/types/index.ts | 5 ++
src/shared/account-types.ts | 130 ++++++++++++++++++++++++++++++++++++
src/shared/index.ts | 1 +
3 files changed, 136 insertions(+)
create mode 100644 src/shared/account-types.ts
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index 217ace0d1..0c69ac1dd 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -705,6 +705,11 @@ export interface Session {
// Symphony contribution metadata (only set for Symphony sessions)
symphonyMetadata?: SymphonySessionMetadata;
+
+ /** Account ID assigned to this session for multiplexing */
+ accountId?: string;
+ /** Display name of the assigned account (for UI display without lookup) */
+ accountName?: string;
}
export interface AgentConfigOption {
diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts
new file mode 100644
index 000000000..abb90a320
--- /dev/null
+++ b/src/shared/account-types.ts
@@ -0,0 +1,130 @@
+/**
+ * Account multiplexing types for managing multiple Claude Code accounts.
+ * Supports usage monitoring, limit tracking, and automatic account switching.
+ */
+
+/** Unique identifier for an account (generated UUID) */
+export type AccountId = string;
+
+/** Current operational status of an account */
+export type AccountStatus = 'active' | 'throttled' | 'expired' | 'disabled';
+
+/** How the account was authenticated */
+export type AccountAuthMethod = 'oauth' | 'api-key';
+
+/** Agent types that support account multiplexing */
+export type MultiplexableAgent = 'claude-code';
+
+/** A registered account profile */
+export interface AccountProfile {
+ id: AccountId;
+ /** Display name derived from OAuth email (e.g., "dr3@example.com") */
+ name: string;
+ /** OAuth email identity — used as the unique natural key */
+ email: string;
+ /** Absolute path to the account's config directory (e.g., "/home/user/.claude-personal") */
+ configDir: string;
+ /** Agent type this account is for */
+ agentType: MultiplexableAgent;
+ /** Current operational status */
+ status: AccountStatus;
+ /** Authentication method used */
+ authMethod: AccountAuthMethod;
+ /** When the account was added to Maestro (ms timestamp) */
+ addedAt: number;
+ /** When the account was last used (ms timestamp) */
+ lastUsedAt: number;
+ /** When the account was last throttled (ms timestamp, 0 if never) */
+ lastThrottledAt: number;
+ /** User-configured token limit per time window (0 = no limit configured) */
+ tokenLimitPerWindow: number;
+ /** Time window for the token limit in milliseconds (default: 5 hours) */
+ tokenWindowMs: number;
+ /** Whether this account is the default for new sessions */
+ isDefault: boolean;
+ /** Whether auto-switching is enabled for this account */
+ autoSwitchEnabled: boolean;
+}
+
+/** Token usage snapshot for a single account within a time window */
+export interface AccountUsageSnapshot {
+ accountId: AccountId;
+ /** Total input tokens consumed in the current window */
+ inputTokens: number;
+ /** Total output tokens consumed in the current window */
+ outputTokens: number;
+ /** Total cache read tokens consumed in the current window */
+ cacheReadTokens: number;
+ /** Total cache creation tokens consumed in the current window */
+ cacheCreationTokens: number;
+ /** Estimated cost in USD for the current window */
+ costUsd: number;
+ /** Window start time (ms timestamp) */
+ windowStart: number;
+ /** Window end time (ms timestamp) */
+ windowEnd: number;
+ /** Number of queries made in the current window */
+ queryCount: number;
+ /** Estimated percentage of limit used (0-100, null if no limit configured) */
+ usagePercent: number | null;
+}
+
+/** Real-time assignment of an account to a session */
+export interface AccountAssignment {
+ sessionId: string;
+ accountId: AccountId;
+ /** When this assignment was made (ms timestamp) */
+ assignedAt: number;
+}
+
+/** Configuration for the account switching behavior */
+export interface AccountSwitchConfig {
+ /** Whether auto-switching is globally enabled */
+ enabled: boolean;
+ /** Whether to prompt the user before switching (default: true) */
+ promptBeforeSwitch: boolean;
+ /** Usage percentage threshold that triggers a switch warning (default: 80) */
+ warningThresholdPercent: number;
+ /** Usage percentage threshold that triggers auto-switch (default: 95) */
+ autoSwitchThresholdPercent: number;
+ /** Strategy for selecting the next account */
+ selectionStrategy: 'least-used' | 'round-robin';
+}
+
+/** Event emitted when an account switch occurs or is suggested */
+export interface AccountSwitchEvent {
+ sessionId: string;
+ fromAccountId: AccountId;
+ toAccountId: AccountId;
+ reason: 'throttled' | 'limit-approaching' | 'manual' | 'auth-expired';
+ /** Whether the switch was automatic (true) or user-initiated (false) */
+ automatic: boolean;
+ /** Timestamp of the event (ms) */
+ timestamp: number;
+}
+
+/** Aggregated usage data for the capacity planner */
+export interface AccountCapacityMetrics {
+ /** Average tokens per hour across all accounts over the analysis window */
+ avgTokensPerHour: number;
+ /** Peak tokens per hour observed */
+ peakTokensPerHour: number;
+ /** Number of throttle events in the analysis window */
+ throttleEvents: number;
+ /** Estimated accounts needed to avoid interruptions */
+ recommendedAccountCount: number;
+ /** Analysis window duration in milliseconds */
+ analysisWindowMs: number;
+}
+
+/** Default values for account switch configuration */
+export const ACCOUNT_SWITCH_DEFAULTS: AccountSwitchConfig = {
+ enabled: false,
+ promptBeforeSwitch: true,
+ warningThresholdPercent: 80,
+ autoSwitchThresholdPercent: 95,
+ selectionStrategy: 'least-used',
+};
+
+/** Default token window: 5 hours in milliseconds */
+export const DEFAULT_TOKEN_WINDOW_MS = 5 * 60 * 60 * 1000;
diff --git a/src/shared/index.ts b/src/shared/index.ts
index 18e2c9a4f..cb71aac10 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -14,3 +14,4 @@ export * from './emojiUtils';
export * from './treeUtils';
export * from './stringUtils';
export * from './pathUtils';
+export * from './account-types';
From 39a4195fea0b8e49d81a1199b16bb67a8c8b87d4 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 01:47:33 -0500
Subject: [PATCH 02/59] MAESTRO: feat: add account registry store and CRUD
service
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements the persistence layer for account multiplexing:
- AccountStoreData schema type for electron-store
- maestro-accounts store instance in initializeStores()
- AccountRegistry class with full CRUD, assignment management,
round-robin/least-used selection, and switch config
- 37-test suite for AccountRegistry covering all operations
- Updated instances.test.ts store count assertion (8→9)
Co-Authored-By: Claude Opus 4.6
---
.../main/accounts/account-registry.test.ts | 364 ++++++++++++++++++
src/__tests__/main/stores/instances.test.ts | 5 +-
src/main/accounts/account-registry.ts | 223 +++++++++++
src/main/stores/account-store-types.ts | 14 +
src/main/stores/getters.ts | 6 +
src/main/stores/index.ts | 2 +
src/main/stores/instances.ts | 21 +
7 files changed, 633 insertions(+), 2 deletions(-)
create mode 100644 src/__tests__/main/accounts/account-registry.test.ts
create mode 100644 src/main/accounts/account-registry.ts
create mode 100644 src/main/stores/account-store-types.ts
diff --git a/src/__tests__/main/accounts/account-registry.test.ts b/src/__tests__/main/accounts/account-registry.test.ts
new file mode 100644
index 000000000..b259470a7
--- /dev/null
+++ b/src/__tests__/main/accounts/account-registry.test.ts
@@ -0,0 +1,364 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { AccountRegistry } from '../../../main/accounts/account-registry';
+import type { AccountStoreData } from '../../../main/stores/account-store-types';
+import { ACCOUNT_SWITCH_DEFAULTS } from '../../../shared/account-types';
+
+// Create a mock store that behaves like electron-store (in-memory)
+function createMockStore(initial?: Partial) {
+ const data: AccountStoreData = {
+ accounts: {},
+ assignments: {},
+ switchConfig: { ...ACCOUNT_SWITCH_DEFAULTS },
+ rotationOrder: [],
+ rotationIndex: 0,
+ ...initial,
+ };
+
+ return {
+ get(key: string, defaultValue?: any) {
+ return (data as any)[key] ?? defaultValue;
+ },
+ set(key: string, value: any) {
+ (data as any)[key] = value;
+ },
+ _data: data,
+ } as any;
+}
+
+function makeParams(overrides: Partial<{ name: string; email: string; configDir: string }> = {}) {
+ return {
+ name: overrides.name ?? 'Test Account',
+ email: overrides.email ?? 'test@example.com',
+ configDir: overrides.configDir ?? '/home/user/.claude-test',
+ ...overrides,
+ };
+}
+
+describe('AccountRegistry', () => {
+ let store: ReturnType;
+ let registry: AccountRegistry;
+
+ beforeEach(() => {
+ store = createMockStore();
+ registry = new AccountRegistry(store);
+ });
+
+ describe('add', () => {
+ it('should create a new account with default values', () => {
+ const profile = registry.add(makeParams());
+
+ expect(profile.id).toBeTruthy();
+ expect(profile.name).toBe('Test Account');
+ expect(profile.email).toBe('test@example.com');
+ expect(profile.configDir).toBe('/home/user/.claude-test');
+ expect(profile.agentType).toBe('claude-code');
+ expect(profile.status).toBe('active');
+ expect(profile.authMethod).toBe('oauth');
+ expect(profile.isDefault).toBe(true); // First account is default
+ expect(profile.autoSwitchEnabled).toBe(true);
+ expect(profile.lastUsedAt).toBe(0);
+ expect(profile.lastThrottledAt).toBe(0);
+ expect(profile.tokenLimitPerWindow).toBe(0);
+ });
+
+ it('should mark only the first account as default', () => {
+ const first = registry.add(makeParams({ email: 'first@example.com' }));
+ const second = registry.add(makeParams({ email: 'second@example.com' }));
+
+ expect(first.isDefault).toBe(true);
+ expect(second.isDefault).toBe(false);
+ });
+
+ it('should throw on duplicate email', () => {
+ registry.add(makeParams({ email: 'dupe@example.com' }));
+
+ expect(() => registry.add(makeParams({ email: 'dupe@example.com' }))).toThrow(
+ 'Account with email "dupe@example.com" already exists'
+ );
+ });
+
+ it('should add account to rotation order', () => {
+ const profile = registry.add(makeParams());
+
+ const order = store.get('rotationOrder');
+ expect(order).toContain(profile.id);
+ });
+
+ it('should accept custom agentType and authMethod', () => {
+ const profile = registry.add({
+ ...makeParams(),
+ agentType: 'claude-code',
+ authMethod: 'api-key',
+ });
+
+ expect(profile.agentType).toBe('claude-code');
+ expect(profile.authMethod).toBe('api-key');
+ });
+ });
+
+ describe('get / getAll', () => {
+ it('should return null for non-existent ID', () => {
+ expect(registry.get('nonexistent')).toBeNull();
+ });
+
+ it('should return the account by ID', () => {
+ const added = registry.add(makeParams());
+
+ expect(registry.get(added.id)).toEqual(added);
+ });
+
+ it('should return all accounts', () => {
+ registry.add(makeParams({ email: 'a@example.com' }));
+ registry.add(makeParams({ email: 'b@example.com' }));
+
+ expect(registry.getAll()).toHaveLength(2);
+ });
+ });
+
+ describe('findByEmail / findByConfigDir', () => {
+ it('should find account by email', () => {
+ const added = registry.add(makeParams({ email: 'find@example.com' }));
+
+ expect(registry.findByEmail('find@example.com')?.id).toBe(added.id);
+ expect(registry.findByEmail('notfound@example.com')).toBeNull();
+ });
+
+ it('should find account by configDir', () => {
+ const added = registry.add(makeParams({ configDir: '/home/user/.claude-special' }));
+
+ expect(registry.findByConfigDir('/home/user/.claude-special')?.id).toBe(added.id);
+ expect(registry.findByConfigDir('/nonexistent')).toBeNull();
+ });
+ });
+
+ describe('update', () => {
+ it('should return null for non-existent ID', () => {
+ expect(registry.update('nonexistent', { name: 'Updated' })).toBeNull();
+ });
+
+ it('should update account fields', () => {
+ const added = registry.add(makeParams());
+ const updated = registry.update(added.id, { name: 'New Name' });
+
+ expect(updated?.name).toBe('New Name');
+ expect(updated?.email).toBe('test@example.com'); // unchanged
+ });
+
+ it('should clear default from other accounts when setting new default', () => {
+ const first = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.add(makeParams({ email: 'b@example.com' }));
+ const second = registry.getAll().find(a => a.email === 'b@example.com')!;
+
+ registry.update(second.id, { isDefault: true });
+
+ expect(registry.get(first.id)?.isDefault).toBe(false);
+ expect(registry.get(second.id)?.isDefault).toBe(true);
+ });
+ });
+
+ describe('remove', () => {
+ it('should return false for non-existent ID', () => {
+ expect(registry.remove('nonexistent')).toBe(false);
+ });
+
+ it('should remove account and clean up rotation order', () => {
+ const added = registry.add(makeParams());
+
+ expect(registry.remove(added.id)).toBe(true);
+ expect(registry.get(added.id)).toBeNull();
+ expect(store.get('rotationOrder')).not.toContain(added.id);
+ });
+
+ it('should remove assignments pointing to the deleted account', () => {
+ const added = registry.add(makeParams());
+ registry.assignToSession('session-1', added.id);
+
+ registry.remove(added.id);
+
+ expect(registry.getAssignment('session-1')).toBeNull();
+ });
+ });
+
+ describe('setStatus', () => {
+ it('should update account status', () => {
+ const added = registry.add(makeParams());
+
+ registry.setStatus(added.id, 'disabled');
+
+ expect(registry.get(added.id)?.status).toBe('disabled');
+ });
+
+ it('should set lastThrottledAt when throttled', () => {
+ const added = registry.add(makeParams());
+ const before = Date.now();
+
+ registry.setStatus(added.id, 'throttled');
+
+ const account = registry.get(added.id)!;
+ expect(account.status).toBe('throttled');
+ expect(account.lastThrottledAt).toBeGreaterThanOrEqual(before);
+ });
+
+ it('should no-op for non-existent ID', () => {
+ // Should not throw
+ registry.setStatus('nonexistent', 'active');
+ });
+ });
+
+ describe('touchLastUsed', () => {
+ it('should update lastUsedAt timestamp', () => {
+ const added = registry.add(makeParams());
+ expect(registry.get(added.id)?.lastUsedAt).toBe(0);
+
+ const before = Date.now();
+ registry.touchLastUsed(added.id);
+
+ expect(registry.get(added.id)?.lastUsedAt).toBeGreaterThanOrEqual(before);
+ });
+ });
+
+ describe('assignments', () => {
+ it('should assign account to session', () => {
+ const added = registry.add(makeParams());
+ const assignment = registry.assignToSession('session-1', added.id);
+
+ expect(assignment.sessionId).toBe('session-1');
+ expect(assignment.accountId).toBe(added.id);
+ expect(assignment.assignedAt).toBeGreaterThan(0);
+ });
+
+ it('should get assignment by session ID', () => {
+ const added = registry.add(makeParams());
+ registry.assignToSession('session-1', added.id);
+
+ const assignment = registry.getAssignment('session-1');
+ expect(assignment?.accountId).toBe(added.id);
+ });
+
+ it('should return null for unassigned session', () => {
+ expect(registry.getAssignment('unknown')).toBeNull();
+ });
+
+ it('should remove assignment', () => {
+ const added = registry.add(makeParams());
+ registry.assignToSession('session-1', added.id);
+
+ registry.removeAssignment('session-1');
+
+ expect(registry.getAssignment('session-1')).toBeNull();
+ });
+
+ it('should get all assignments', () => {
+ const added = registry.add(makeParams());
+ registry.assignToSession('session-1', added.id);
+ registry.assignToSession('session-2', added.id);
+
+ expect(registry.getAllAssignments()).toHaveLength(2);
+ });
+
+ it('should touch lastUsedAt on assignment', () => {
+ const added = registry.add(makeParams());
+ expect(registry.get(added.id)?.lastUsedAt).toBe(0);
+
+ registry.assignToSession('session-1', added.id);
+
+ expect(registry.get(added.id)?.lastUsedAt).toBeGreaterThan(0);
+ });
+ });
+
+ describe('getDefaultAccount', () => {
+ it('should return null when no accounts exist', () => {
+ expect(registry.getDefaultAccount()).toBeNull();
+ });
+
+ it('should return the default active account', () => {
+ const added = registry.add(makeParams());
+
+ expect(registry.getDefaultAccount()?.id).toBe(added.id);
+ });
+
+ it('should fall back to first active account if default is disabled', () => {
+ const first = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.add(makeParams({ email: 'b@example.com' }));
+
+ registry.setStatus(first.id, 'disabled');
+
+ const defaultAcct = registry.getDefaultAccount();
+ expect(defaultAcct?.email).toBe('b@example.com');
+ });
+ });
+
+ describe('selectNextAccount', () => {
+ it('should return null when no accounts available', () => {
+ expect(registry.selectNextAccount()).toBeNull();
+ });
+
+ it('should select least-used account by default', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+
+ // Touch a so b is least-used
+ registry.touchLastUsed(a.id);
+
+ const next = registry.selectNextAccount();
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should exclude specified account IDs', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+
+ const next = registry.selectNextAccount([a.id]);
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should return null when all accounts are excluded', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+
+ expect(registry.selectNextAccount([a.id])).toBeNull();
+ });
+
+ it('should skip disabled accounts', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+
+ registry.setStatus(a.id, 'disabled');
+
+ const next = registry.selectNextAccount();
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should use round-robin when configured', () => {
+ registry.updateSwitchConfig({ selectionStrategy: 'round-robin' });
+
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+
+ const first = registry.selectNextAccount();
+ const second = registry.selectNextAccount();
+
+ // Should cycle through accounts
+ expect([first?.id, second?.id]).toContain(a.id);
+ expect([first?.id, second?.id]).toContain(b.id);
+ });
+ });
+
+ describe('switchConfig', () => {
+ it('should return defaults initially', () => {
+ const config = registry.getSwitchConfig();
+
+ expect(config).toEqual(ACCOUNT_SWITCH_DEFAULTS);
+ });
+
+ it('should update config partially', () => {
+ const updated = registry.updateSwitchConfig({
+ enabled: true,
+ warningThresholdPercent: 70,
+ });
+
+ expect(updated.enabled).toBe(true);
+ expect(updated.warningThresholdPercent).toBe(70);
+ expect(updated.promptBeforeSwitch).toBe(true); // unchanged default
+ });
+ });
+});
diff --git a/src/__tests__/main/stores/instances.test.ts b/src/__tests__/main/stores/instances.test.ts
index 212d0e150..8179dd90e 100644
--- a/src/__tests__/main/stores/instances.test.ts
+++ b/src/__tests__/main/stores/instances.test.ts
@@ -60,8 +60,8 @@ describe('stores/instances', () => {
it('should initialize all stores', () => {
const result = initializeStores({ productionDataPath: '/mock/production/path' });
- // Should create 8 stores
- expect(mockStoreConstructorCalls).toHaveLength(8);
+ // Should create 9 stores
+ expect(mockStoreConstructorCalls).toHaveLength(9);
// Should return syncPath and bootstrapStore
expect(result.syncPath).toBe('/mock/user/data');
@@ -167,6 +167,7 @@ describe('stores/instances', () => {
expect(instances.windowStateStore).toBeDefined();
expect(instances.claudeSessionOriginsStore).toBeDefined();
expect(instances.agentSessionOriginsStore).toBeDefined();
+ expect(instances.accountStore).toBeDefined();
});
});
diff --git a/src/main/accounts/account-registry.ts b/src/main/accounts/account-registry.ts
new file mode 100644
index 000000000..52887484b
--- /dev/null
+++ b/src/main/accounts/account-registry.ts
@@ -0,0 +1,223 @@
+import type Store from 'electron-store';
+import type { AccountStoreData } from '../stores/account-store-types';
+import type {
+ AccountProfile,
+ AccountAssignment,
+ AccountSwitchConfig,
+ AccountId,
+ AccountStatus,
+} from '../../shared/account-types';
+import { DEFAULT_TOKEN_WINDOW_MS, ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
+import { generateUUID } from '../../shared/uuid';
+
+export class AccountRegistry {
+ constructor(private store: Store) {}
+
+ // --- Account CRUD ---
+
+ /** Get all registered accounts */
+ getAll(): AccountProfile[] {
+ const accounts = this.store.get('accounts', {});
+ return Object.values(accounts);
+ }
+
+ /** Get a single account by ID */
+ get(id: AccountId): AccountProfile | null {
+ const accounts = this.store.get('accounts', {});
+ return accounts[id] ?? null;
+ }
+
+ /** Find account by email */
+ findByEmail(email: string): AccountProfile | null {
+ return this.getAll().find(a => a.email === email) ?? null;
+ }
+
+ /** Find account by config directory path */
+ findByConfigDir(configDir: string): AccountProfile | null {
+ return this.getAll().find(a => a.configDir === configDir) ?? null;
+ }
+
+ /** Register a new account. Returns the created profile. */
+ add(params: {
+ name: string;
+ email: string;
+ configDir: string;
+ agentType?: 'claude-code';
+ authMethod?: 'oauth' | 'api-key';
+ }): AccountProfile {
+ // Check for duplicate email
+ const existing = this.findByEmail(params.email);
+ if (existing) {
+ throw new Error(`Account with email "${params.email}" already exists (ID: ${existing.id})`);
+ }
+
+ const now = Date.now();
+ const isFirst = this.getAll().length === 0;
+ const profile: AccountProfile = {
+ id: generateUUID(),
+ name: params.name,
+ email: params.email,
+ configDir: params.configDir,
+ agentType: params.agentType ?? 'claude-code',
+ status: 'active',
+ authMethod: params.authMethod ?? 'oauth',
+ addedAt: now,
+ lastUsedAt: 0,
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: DEFAULT_TOKEN_WINDOW_MS,
+ isDefault: isFirst, // First account is default
+ autoSwitchEnabled: true,
+ };
+
+ const accounts = this.store.get('accounts', {});
+ accounts[profile.id] = profile;
+ this.store.set('accounts', accounts);
+
+ // Add to rotation order
+ const order = this.store.get('rotationOrder', []);
+ order.push(profile.id);
+ this.store.set('rotationOrder', order);
+
+ return profile;
+ }
+
+ /** Update an existing account profile. Returns updated profile or null if not found. */
+ update(id: AccountId, updates: Partial>): AccountProfile | null {
+ const accounts = this.store.get('accounts', {});
+ const existing = accounts[id];
+ if (!existing) return null;
+
+ // If setting this as default, clear default from others
+ if (updates.isDefault) {
+ for (const acct of Object.values(accounts)) {
+ acct.isDefault = false;
+ }
+ }
+
+ accounts[id] = { ...existing, ...updates };
+ this.store.set('accounts', accounts);
+ return accounts[id];
+ }
+
+ /** Remove an account. Returns true if found and removed. */
+ remove(id: AccountId): boolean {
+ const accounts = this.store.get('accounts', {});
+ if (!accounts[id]) return false;
+
+ delete accounts[id];
+ this.store.set('accounts', accounts);
+
+ // Remove from rotation order
+ const order = this.store.get('rotationOrder', []);
+ this.store.set('rotationOrder', order.filter(aid => aid !== id));
+
+ // Remove any assignments pointing to this account
+ const assignments = this.store.get('assignments', {});
+ for (const [sid, assignment] of Object.entries(assignments)) {
+ if (assignment.accountId === id) {
+ delete assignments[sid];
+ }
+ }
+ this.store.set('assignments', assignments);
+
+ return true;
+ }
+
+ /** Update account status (active, throttled, expired, disabled) */
+ setStatus(id: AccountId, status: AccountStatus): void {
+ const accounts = this.store.get('accounts', {});
+ if (!accounts[id]) return;
+ accounts[id].status = status;
+ if (status === 'throttled') {
+ accounts[id].lastThrottledAt = Date.now();
+ }
+ this.store.set('accounts', accounts);
+ }
+
+ /** Mark account as recently used */
+ touchLastUsed(id: AccountId): void {
+ const accounts = this.store.get('accounts', {});
+ if (!accounts[id]) return;
+ accounts[id].lastUsedAt = Date.now();
+ this.store.set('accounts', accounts);
+ }
+
+ // --- Assignments ---
+
+ /** Assign an account to a session */
+ assignToSession(sessionId: string, accountId: AccountId): AccountAssignment {
+ const assignment: AccountAssignment = {
+ sessionId,
+ accountId,
+ assignedAt: Date.now(),
+ };
+ const assignments = this.store.get('assignments', {});
+ assignments[sessionId] = assignment;
+ this.store.set('assignments', assignments);
+ this.touchLastUsed(accountId);
+ return assignment;
+ }
+
+ /** Get the account assigned to a session */
+ getAssignment(sessionId: string): AccountAssignment | null {
+ const assignments = this.store.get('assignments', {});
+ return assignments[sessionId] ?? null;
+ }
+
+ /** Remove a session assignment (e.g., when session is closed) */
+ removeAssignment(sessionId: string): void {
+ const assignments = this.store.get('assignments', {});
+ delete assignments[sessionId];
+ this.store.set('assignments', assignments);
+ }
+
+ /** Get all current assignments */
+ getAllAssignments(): AccountAssignment[] {
+ return Object.values(this.store.get('assignments', {}));
+ }
+
+ /** Get the default account (first one marked isDefault, or first active) */
+ getDefaultAccount(): AccountProfile | null {
+ const all = this.getAll();
+ return all.find(a => a.isDefault && a.status === 'active')
+ ?? all.find(a => a.status === 'active')
+ ?? null;
+ }
+
+ /** Select the next account using the configured strategy */
+ selectNextAccount(excludeIds: AccountId[] = []): AccountProfile | null {
+ const config = this.getSwitchConfig();
+ const available = this.getAll().filter(
+ a => a.status === 'active' && a.autoSwitchEnabled && !excludeIds.includes(a.id)
+ );
+ if (available.length === 0) return null;
+
+ if (config.selectionStrategy === 'round-robin') {
+ const order = this.store.get('rotationOrder', []).filter(
+ id => available.some(a => a.id === id)
+ );
+ if (order.length === 0) return available[0];
+ const idx = (this.store.get('rotationIndex', 0) + 1) % order.length;
+ this.store.set('rotationIndex', idx);
+ return available.find(a => a.id === order[idx]) ?? available[0];
+ }
+
+ // least-used: sort by lastUsedAt ascending (least recently used first)
+ available.sort((a, b) => a.lastUsedAt - b.lastUsedAt);
+ return available[0];
+ }
+
+ // --- Switch Config ---
+
+ getSwitchConfig(): AccountSwitchConfig {
+ return this.store.get('switchConfig', ACCOUNT_SWITCH_DEFAULTS);
+ }
+
+ updateSwitchConfig(updates: Partial): AccountSwitchConfig {
+ const current = this.getSwitchConfig();
+ const updated = { ...current, ...updates };
+ this.store.set('switchConfig', updated);
+ return updated;
+ }
+}
diff --git a/src/main/stores/account-store-types.ts b/src/main/stores/account-store-types.ts
new file mode 100644
index 000000000..c25d25a42
--- /dev/null
+++ b/src/main/stores/account-store-types.ts
@@ -0,0 +1,14 @@
+import type { AccountProfile, AccountAssignment, AccountSwitchConfig } from '../../shared/account-types';
+
+export interface AccountStoreData {
+ /** All registered account profiles, keyed by account ID */
+ accounts: Record;
+ /** Current session-to-account assignments, keyed by session ID */
+ assignments: Record;
+ /** Global account switching configuration */
+ switchConfig: AccountSwitchConfig;
+ /** Ordered list of account IDs for round-robin assignment */
+ rotationOrder: string[];
+ /** Index of the last account used in round-robin rotation */
+ rotationIndex: number;
+}
diff --git a/src/main/stores/getters.ts b/src/main/stores/getters.ts
index 84be70710..39c343b32 100644
--- a/src/main/stores/getters.ts
+++ b/src/main/stores/getters.ts
@@ -17,6 +17,7 @@ import type {
ClaudeSessionOriginsData,
AgentSessionOriginsData,
} from './types';
+import type { AccountStoreData } from './account-store-types';
import type { SshRemoteConfig } from '../../shared/types';
import { isInitialized, getStoreInstances, getCachedPaths } from './instances';
@@ -78,6 +79,11 @@ export function getAgentSessionOriginsStore(): Store {
return getStoreInstances().agentSessionOriginsStore!;
}
+export function getAccountStore(): Store {
+ ensureInitialized();
+ return getStoreInstances().accountStore!;
+}
+
// ============================================================================
// Path Getters
// ============================================================================
diff --git a/src/main/stores/index.ts b/src/main/stores/index.ts
index f47486596..7c9e4b437 100644
--- a/src/main/stores/index.ts
+++ b/src/main/stores/index.ts
@@ -25,6 +25,7 @@
// ============================================================================
export * from './types';
+export type { AccountStoreData } from './account-store-types';
// ============================================================================
// Store Initialization
@@ -46,6 +47,7 @@ export {
getWindowStateStore,
getClaudeSessionOriginsStore,
getAgentSessionOriginsStore,
+ getAccountStore,
getSyncPath,
getProductionDataPath,
getSshRemoteById,
diff --git a/src/main/stores/instances.ts b/src/main/stores/instances.ts
index a38950799..f5989fdaf 100644
--- a/src/main/stores/instances.ts
+++ b/src/main/stores/instances.ts
@@ -24,6 +24,8 @@ import type {
AgentSessionOriginsData,
} from './types';
+import type { AccountStoreData } from './account-store-types';
+
import {
SETTINGS_DEFAULTS,
SESSIONS_DEFAULTS,
@@ -34,6 +36,8 @@ import {
AGENT_SESSION_ORIGINS_DEFAULTS,
} from './defaults';
+import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
+
import { getCustomSyncPath } from './utils';
// ============================================================================
@@ -48,6 +52,15 @@ let _agentConfigsStore: Store | null = null;
let _windowStateStore: Store | null = null;
let _claudeSessionOriginsStore: Store | null = null;
let _agentSessionOriginsStore: Store | null = null;
+let _accountStore: Store | null = null;
+
+const ACCOUNT_STORE_DEFAULTS: AccountStoreData = {
+ accounts: {},
+ assignments: {},
+ switchConfig: ACCOUNT_SWITCH_DEFAULTS,
+ rotationOrder: [],
+ rotationIndex: 0,
+};
// Cached paths after initialization
let _syncPath: string | null = null;
@@ -137,6 +150,13 @@ export function initializeStores(options: StoreInitOptions): {
defaults: AGENT_SESSION_ORIGINS_DEFAULTS,
});
+ // Account multiplexing - manages multiple Claude Code accounts
+ _accountStore = new Store({
+ name: 'maestro-accounts',
+ cwd: _syncPath,
+ defaults: ACCOUNT_STORE_DEFAULTS,
+ });
+
return {
syncPath: _syncPath,
bootstrapStore: _bootstrapStore,
@@ -163,6 +183,7 @@ export function getStoreInstances() {
windowStateStore: _windowStateStore,
claudeSessionOriginsStore: _claudeSessionOriginsStore,
agentSessionOriginsStore: _agentSessionOriginsStore,
+ accountStore: _accountStore,
};
}
From d3a9cf1423aee4836e6e48f55f08cf4e18ae9ebb Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 01:52:47 -0500
Subject: [PATCH 03/59] MAESTRO: feat: add account usage tracking to stats
database (v4 migration)
- Bump STATS_DB_VERSION from 3 to 4
- Add migration v4: account_id + token/cost columns to query_events,
account_usage_windows table, account_throttle_events table
- Extend QueryEvent interface with accountId, token counts, and costUsd
- Update insertQueryEvent INSERT to include new columns
- Add account-usage.ts module with upsertAccountUsageWindow,
getAccountUsageInWindow, insertThrottleEvent, getThrottleEvents
- Wire new module into StatsDB class following delegated pattern
- Update row-mappers for new QueryEventRow fields
- Fix test expectations for version 4 and new INSERT parameters
Co-Authored-By: Claude Opus 4.6
---
src/__tests__/main/stats/paths.test.ts | 30 ++-
src/__tests__/main/stats/stats-db.test.ts | 16 +-
src/main/stats/account-usage.ts | 216 ++++++++++++++++++++++
src/main/stats/migrations.ts | 36 ++++
src/main/stats/query-events.ts | 12 +-
src/main/stats/row-mappers.ts | 12 ++
src/main/stats/schema.ts | 47 +++++
src/main/stats/stats-db.ts | 31 ++++
src/shared/stats-types.ts | 14 +-
9 files changed, 396 insertions(+), 18 deletions(-)
create mode 100644 src/main/stats/account-usage.ts
diff --git a/src/__tests__/main/stats/paths.test.ts b/src/__tests__/main/stats/paths.test.ts
index 6e94cfc6d..147e7b3c7 100644
--- a/src/__tests__/main/stats/paths.test.ts
+++ b/src/__tests__/main/stats/paths.test.ts
@@ -714,7 +714,7 @@ describe('File path normalization in database (forward slashes consistently)', (
});
// Verify that the statement was called with normalized path
- // insertQueryEvent now has 9 parameters: id, sessionId, agentType, source, startTime, duration, projectPath, tabId, isRemote
+ // insertQueryEvent now has 15 parameters: id, sessionId, agentType, source, startTime, duration, projectPath, tabId, isRemote, accountId, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, costUsd
expect(mockStatement.run).toHaveBeenCalledWith(
expect.any(String), // id
'session-1',
@@ -724,7 +724,13 @@ describe('File path normalization in database (forward slashes consistently)', (
5000,
'C:/Users/TestUser/Projects/MyApp', // normalized path
'tab-1',
- null // isRemote (undefined → null)
+ null, // isRemote (undefined → null)
+ null, // accountId
+ 0, // inputTokens
+ 0, // outputTokens
+ 0, // cacheReadTokens
+ 0, // cacheCreationTokens
+ 0 // costUsd
);
});
@@ -743,7 +749,7 @@ describe('File path normalization in database (forward slashes consistently)', (
tabId: 'tab-1',
});
- // insertQueryEvent now has 9 parameters including isRemote
+ // insertQueryEvent now has 15 parameters including isRemote and account usage fields
expect(mockStatement.run).toHaveBeenCalledWith(
expect.any(String),
'session-1',
@@ -753,7 +759,13 @@ describe('File path normalization in database (forward slashes consistently)', (
5000,
'/Users/testuser/Projects/MyApp', // unchanged
'tab-1',
- null // isRemote (undefined → null)
+ null, // isRemote (undefined → null)
+ null, // accountId
+ 0, // inputTokens
+ 0, // outputTokens
+ 0, // cacheReadTokens
+ 0, // cacheCreationTokens
+ 0 // costUsd
);
});
@@ -771,7 +783,7 @@ describe('File path normalization in database (forward slashes consistently)', (
// projectPath is undefined
});
- // insertQueryEvent now has 9 parameters including isRemote
+ // insertQueryEvent now has 15 parameters including isRemote and account usage fields
expect(mockStatement.run).toHaveBeenCalledWith(
expect.any(String),
'session-1',
@@ -781,7 +793,13 @@ describe('File path normalization in database (forward slashes consistently)', (
5000,
null, // undefined becomes null
null, // tabId undefined → null
- null // isRemote undefined → null
+ null, // isRemote undefined → null
+ null, // accountId
+ 0, // inputTokens
+ 0, // outputTokens
+ 0, // cacheReadTokens
+ 0, // cacheCreationTokens
+ 0 // costUsd
);
});
});
diff --git a/src/__tests__/main/stats/stats-db.test.ts b/src/__tests__/main/stats/stats-db.test.ts
index 237184014..ac1daddc9 100644
--- a/src/__tests__/main/stats/stats-db.test.ts
+++ b/src/__tests__/main/stats/stats-db.test.ts
@@ -284,13 +284,13 @@ describe('StatsDB class (mocked)', () => {
const db = new StatsDB();
db.initialize();
- // Currently we have version 3 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table)
- expect(db.getTargetVersion()).toBe(3);
+ // Currently we have version 4 migration (v1: initial schema, v2: is_remote column, v3: session_lifecycle table, v4: account usage tracking)
+ expect(db.getTargetVersion()).toBe(4);
});
it('should return false from hasPendingMigrations() when up to date', async () => {
mockDb.pragma.mockImplementation((sql: string) => {
- if (sql === 'user_version') return [{ user_version: 3 }];
+ if (sql === 'user_version') return [{ user_version: 4 }];
return undefined;
});
@@ -305,8 +305,8 @@ describe('StatsDB class (mocked)', () => {
// This test verifies the hasPendingMigrations() logic
// by checking current version < target version
- // Simulate a database that's already at version 3 (target version)
- let currentVersion = 3;
+ // Simulate a database that's already at version 4 (target version)
+ let currentVersion = 4;
mockDb.pragma.mockImplementation((sql: string) => {
if (sql === 'user_version') return [{ user_version: currentVersion }];
// Handle version updates from migration
@@ -320,9 +320,9 @@ describe('StatsDB class (mocked)', () => {
const db = new StatsDB();
db.initialize();
- // At version 3, target is 3, so no pending migrations
- expect(db.getCurrentVersion()).toBe(3);
- expect(db.getTargetVersion()).toBe(3);
+ // At version 4, target is 4, so no pending migrations
+ expect(db.getCurrentVersion()).toBe(4);
+ expect(db.getTargetVersion()).toBe(4);
expect(db.hasPendingMigrations()).toBe(false);
});
diff --git a/src/main/stats/account-usage.ts b/src/main/stats/account-usage.ts
new file mode 100644
index 000000000..ce3901ea1
--- /dev/null
+++ b/src/main/stats/account-usage.ts
@@ -0,0 +1,216 @@
+/**
+ * Account Usage Tracking Operations
+ *
+ * Handles windowed usage aggregation per account and throttle event recording
+ * for capacity planning and account multiplexing.
+ */
+
+import type Database from 'better-sqlite3';
+import { generateId, LOG_CONTEXT } from './utils';
+import { StatementCache } from './utils';
+import { logger } from '../utils/logger';
+
+const stmtCache = new StatementCache();
+
+// ============================================================================
+// Account Usage Windows
+// ============================================================================
+
+export interface AccountUsageTokens {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ costUsd: number;
+}
+
+export interface AccountUsageSummary {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ costUsd: number;
+ queryCount: number;
+}
+
+export interface ThrottleEvent {
+ id: string;
+ accountId: string;
+ sessionId: string | null;
+ timestamp: number;
+ reason: string;
+ tokensAtThrottle: number;
+}
+
+const UPSERT_CHECK_SQL = `
+ SELECT id, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, query_count
+ FROM account_usage_windows WHERE account_id = ? AND window_start = ?
+`;
+
+const UPDATE_WINDOW_SQL = `
+ UPDATE account_usage_windows SET
+ input_tokens = input_tokens + ?,
+ output_tokens = output_tokens + ?,
+ cache_read_tokens = cache_read_tokens + ?,
+ cache_creation_tokens = cache_creation_tokens + ?,
+ cost_usd = cost_usd + ?,
+ query_count = query_count + 1
+ WHERE id = ?
+`;
+
+const INSERT_WINDOW_SQL = `
+ INSERT INTO account_usage_windows (id, account_id, window_start, window_end, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd, query_count, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
+`;
+
+const GET_USAGE_SQL = `
+ SELECT
+ COALESCE(SUM(input_tokens), 0) as inputTokens,
+ COALESCE(SUM(output_tokens), 0) as outputTokens,
+ COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens,
+ COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens,
+ COALESCE(SUM(cost_usd), 0) as costUsd,
+ COALESCE(SUM(query_count), 0) as queryCount
+ FROM account_usage_windows
+ WHERE account_id = ? AND window_start >= ? AND window_end <= ?
+`;
+
+const INSERT_THROTTLE_SQL = `
+ INSERT INTO account_throttle_events (id, account_id, session_id, timestamp, reason, tokens_at_throttle, window_start, window_end)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+`;
+
+/**
+ * Record or update a usage window for an account.
+ * If a window with the same account_id and window_start exists, increments the totals.
+ * Otherwise, inserts a new window record.
+ */
+export function upsertAccountUsageWindow(
+ db: Database.Database,
+ accountId: string,
+ windowStart: number,
+ windowEnd: number,
+ tokens: AccountUsageTokens
+): void {
+ const existing = stmtCache.get(db, UPSERT_CHECK_SQL).get(accountId, windowStart) as
+ | { id: string }
+ | undefined;
+
+ if (existing) {
+ stmtCache.get(db, UPDATE_WINDOW_SQL).run(
+ tokens.inputTokens,
+ tokens.outputTokens,
+ tokens.cacheReadTokens,
+ tokens.cacheCreationTokens,
+ tokens.costUsd,
+ existing.id
+ );
+ logger.debug(`Updated usage window ${existing.id} for account ${accountId}`, LOG_CONTEXT);
+ } else {
+ const id = generateId();
+ stmtCache.get(db, INSERT_WINDOW_SQL).run(
+ id,
+ accountId,
+ windowStart,
+ windowEnd,
+ tokens.inputTokens,
+ tokens.outputTokens,
+ tokens.cacheReadTokens,
+ tokens.cacheCreationTokens,
+ tokens.costUsd,
+ Date.now()
+ );
+ logger.debug(`Inserted usage window ${id} for account ${accountId}`, LOG_CONTEXT);
+ }
+}
+
+/**
+ * Get usage for an account within a specific time window.
+ */
+export function getAccountUsageInWindow(
+ db: Database.Database,
+ accountId: string,
+ windowStart: number,
+ windowEnd: number
+): AccountUsageSummary {
+ const result = stmtCache.get(db, GET_USAGE_SQL).get(accountId, windowStart, windowEnd) as AccountUsageSummary;
+ return result;
+}
+
+// ============================================================================
+// Throttle Events
+// ============================================================================
+
+/**
+ * Record a throttle event for capacity planning.
+ */
+export function insertThrottleEvent(
+ db: Database.Database,
+ accountId: string,
+ sessionId: string | null,
+ reason: string,
+ tokensAtThrottle: number,
+ windowStart?: number,
+ windowEnd?: number
+): string {
+ const id = generateId();
+ stmtCache.get(db, INSERT_THROTTLE_SQL).run(
+ id,
+ accountId,
+ sessionId,
+ Date.now(),
+ reason,
+ tokensAtThrottle,
+ windowStart ?? null,
+ windowEnd ?? null
+ );
+ logger.debug(`Inserted throttle event ${id} for account ${accountId}`, LOG_CONTEXT);
+ return id;
+}
+
+/**
+ * Get throttle events for capacity planning, optionally filtered by account and time.
+ */
+export function getThrottleEvents(
+ db: Database.Database,
+ accountId?: string,
+ since?: number
+): ThrottleEvent[] {
+ let sql = 'SELECT * FROM account_throttle_events WHERE 1=1';
+ const params: (string | number)[] = [];
+
+ if (accountId) {
+ sql += ' AND account_id = ?';
+ params.push(accountId);
+ }
+ if (since) {
+ sql += ' AND timestamp >= ?';
+ params.push(since);
+ }
+ sql += ' ORDER BY timestamp DESC';
+
+ const rows = db.prepare(sql).all(...params) as Array<{
+ id: string;
+ account_id: string;
+ session_id: string | null;
+ timestamp: number;
+ reason: string;
+ tokens_at_throttle: number;
+ }>;
+
+ return rows.map((row) => ({
+ id: row.id,
+ accountId: row.account_id,
+ sessionId: row.session_id,
+ timestamp: row.timestamp,
+ reason: row.reason,
+ tokensAtThrottle: row.tokens_at_throttle,
+ }));
+}
+
+/**
+ * Clear the statement cache (call when database is closed)
+ */
+export function clearAccountUsageCache(): void {
+ stmtCache.clear();
+}
diff --git a/src/main/stats/migrations.ts b/src/main/stats/migrations.ts
index 4d356cee2..45043496d 100644
--- a/src/main/stats/migrations.ts
+++ b/src/main/stats/migrations.ts
@@ -24,6 +24,10 @@ import {
CREATE_AUTO_RUN_TASKS_INDEXES_SQL,
CREATE_SESSION_LIFECYCLE_SQL,
CREATE_SESSION_LIFECYCLE_INDEXES_SQL,
+ CREATE_ACCOUNT_USAGE_WINDOWS_SQL,
+ CREATE_ACCOUNT_USAGE_WINDOWS_INDEXES_SQL,
+ CREATE_ACCOUNT_THROTTLE_EVENTS_SQL,
+ CREATE_ACCOUNT_THROTTLE_EVENTS_INDEXES_SQL,
runStatements,
} from './schema';
import { LOG_CONTEXT } from './utils';
@@ -54,6 +58,11 @@ export function getMigrations(): Migration[] {
description: 'Add session_lifecycle table for tracking session creation and closure',
up: (db) => migrateV3(db),
},
+ {
+ version: 4,
+ description: 'Add account usage tracking columns and tables',
+ up: (db) => migrateV4(db),
+ },
];
}
@@ -232,3 +241,30 @@ function migrateV3(db: Database.Database): void {
logger.debug('Created session_lifecycle table', LOG_CONTEXT);
}
+
+/**
+ * Migration v4: Add account usage tracking columns and tables
+ *
+ * - Adds account_id and token/cost columns to query_events
+ * - Creates account_usage_windows table for windowed aggregation
+ * - Creates account_throttle_events table for throttle history
+ */
+function migrateV4(db: Database.Database): void {
+ // Add account_id and token/cost columns to query_events
+ db.prepare('ALTER TABLE query_events ADD COLUMN account_id TEXT DEFAULT NULL').run();
+ db.prepare('ALTER TABLE query_events ADD COLUMN input_tokens INTEGER DEFAULT 0').run();
+ db.prepare('ALTER TABLE query_events ADD COLUMN output_tokens INTEGER DEFAULT 0').run();
+ db.prepare('ALTER TABLE query_events ADD COLUMN cache_read_tokens INTEGER DEFAULT 0').run();
+ db.prepare('ALTER TABLE query_events ADD COLUMN cache_creation_tokens INTEGER DEFAULT 0').run();
+ db.prepare('ALTER TABLE query_events ADD COLUMN cost_usd REAL DEFAULT 0').run();
+
+ // Create account_usage_windows table
+ db.prepare(CREATE_ACCOUNT_USAGE_WINDOWS_SQL).run();
+ runStatements(db, CREATE_ACCOUNT_USAGE_WINDOWS_INDEXES_SQL);
+
+ // Create account_throttle_events table
+ db.prepare(CREATE_ACCOUNT_THROTTLE_EVENTS_SQL).run();
+ runStatements(db, CREATE_ACCOUNT_THROTTLE_EVENTS_INDEXES_SQL);
+
+ logger.debug('Added account usage tracking columns and tables', LOG_CONTEXT);
+}
diff --git a/src/main/stats/query-events.ts b/src/main/stats/query-events.ts
index c39d7b36d..a9bc66a2b 100644
--- a/src/main/stats/query-events.ts
+++ b/src/main/stats/query-events.ts
@@ -14,8 +14,8 @@ import { logger } from '../utils/logger';
const stmtCache = new StatementCache();
const INSERT_SQL = `
- INSERT INTO query_events (id, session_id, agent_type, source, start_time, duration, project_path, tab_id, is_remote)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO query_events (id, session_id, agent_type, source, start_time, duration, project_path, tab_id, is_remote, account_id, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
/**
@@ -34,7 +34,13 @@ export function insertQueryEvent(db: Database.Database, event: Omit
Date: Sun, 15 Feb 2026 09:16:19 -0500
Subject: [PATCH 04/59] MAESTRO: feat: add account multiplexing IPC handlers
and preload bridge
Registers 16 IPC handlers for the accounts: namespace covering CRUD,
assignments, usage queries, throttle events, switch config, and account
selection. Extends the preload accounts API with corresponding invoke
methods alongside the existing event listeners from ACCT-MUX-04. Wires
the account usage listener into the process listener module for
real-time usage tracking and limit notifications.
Co-Authored-By: Claude Opus 4.6
---
src/main/index.ts | 19 ++
src/main/ipc/handlers/accounts.ts | 237 ++++++++++++++++++
src/main/ipc/handlers/index.ts | 3 +
src/main/preload/accounts.ts | 155 ++++++++++++
src/main/preload/index.ts | 12 +
.../account-usage-listener.ts | 128 ++++++++++
src/main/process-listeners/index.ts | 11 +
src/main/process-listeners/types.ts | 3 +
8 files changed, 568 insertions(+)
create mode 100644 src/main/ipc/handlers/accounts.ts
create mode 100644 src/main/preload/accounts.ts
create mode 100644 src/main/process-listeners/account-usage-listener.ts
diff --git a/src/main/index.ts b/src/main/index.ts
index 0ddbcb3e7..2254aec6f 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -52,11 +52,14 @@ import {
registerTabNamingHandlers,
registerAgentErrorHandlers,
registerDirectorNotesHandlers,
+ registerAccountHandlers,
setupLoggerEventForwarding,
cleanupAllGroomingSessions,
getActiveGroomingSessionCount,
} from './ipc/handlers';
import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats';
+import { AccountRegistry } from './accounts/account-registry';
+import { getAccountStore } from './stores';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import {
routeModeratorResponse,
@@ -223,6 +226,7 @@ let mainWindow: BrowserWindow | null = null;
let processManager: ProcessManager | null = null;
let webServer: WebServer | null = null;
let agentDetector: AgentDetector | null = null;
+let accountRegistry: AccountRegistry | null = null;
// Create safeSend with dependency injection (Phase 2 refactoring)
const safeSend = createSafeSend(() => mainWindow);
@@ -342,6 +346,15 @@ app.whenReady().then(async () => {
logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup');
}
+ // Initialize account registry for account multiplexing
+ try {
+ accountRegistry = new AccountRegistry(getAccountStore());
+ logger.info('Account registry initialized', 'Startup');
+ } catch (error) {
+ logger.error(`Failed to initialize account registry: ${error}`, 'Startup');
+ logger.warn('Continuing without account multiplexing', 'Startup');
+ }
+
// Set up IPC handlers
logger.debug('Setting up IPC handlers', 'Startup');
setupIpcHandlers();
@@ -551,6 +564,11 @@ function setupIpcHandlers() {
settingsStore: store,
});
+ // Register Account Multiplexing handlers (CRUD, assignments, usage queries)
+ registerAccountHandlers({
+ getAccountRegistry: () => accountRegistry,
+ });
+
// Register Document Graph handlers for file watching
registerDocumentGraphHandlers({
getMainWindow: () => mainWindow,
@@ -682,6 +700,7 @@ function setupProcessListeners() {
calculateContextTokens,
},
getStatsDB,
+ getAccountRegistry: () => accountRegistry,
debugLog,
patterns: {
REGEX_MODERATOR_SESSION,
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
new file mode 100644
index 000000000..81c772306
--- /dev/null
+++ b/src/main/ipc/handlers/accounts.ts
@@ -0,0 +1,237 @@
+/**
+ * Account Multiplexing IPC Handlers
+ *
+ * Registers IPC handlers for all account management operations:
+ * - CRUD operations for account profiles
+ * - Session-to-account assignments
+ * - Usage queries (windowed token consumption)
+ * - Throttle event queries (capacity planning)
+ * - Switch configuration management
+ * - Account selection (default, next available)
+ */
+
+import { ipcMain } from 'electron';
+import type { AccountRegistry } from '../../accounts/account-registry';
+import type { AccountSwitchConfig } from '../../../shared/account-types';
+import { getStatsDB } from '../../stats';
+import { logger } from '../../utils/logger';
+
+const LOG_CONTEXT = '[Accounts]';
+
+/**
+ * Dependencies for account handlers
+ */
+export interface AccountHandlerDependencies {
+ getAccountRegistry: () => AccountRegistry | null;
+}
+
+/**
+ * Register all account multiplexing IPC handlers.
+ */
+export function registerAccountHandlers(deps: AccountHandlerDependencies): void {
+ const { getAccountRegistry } = deps;
+
+ /** Get the account registry or throw if not initialized */
+ function requireRegistry(): AccountRegistry {
+ const registry = getAccountRegistry();
+ if (!registry) {
+ throw new Error('Account registry not initialized');
+ }
+ return registry;
+ }
+
+ // --- Account CRUD ---
+
+ ipcMain.handle('accounts:list', async () => {
+ try {
+ return requireRegistry().getAll();
+ } catch (error) {
+ logger.error('list accounts error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
+ ipcMain.handle('accounts:get', async (_event, accountId: string) => {
+ try {
+ return requireRegistry().get(accountId);
+ } catch (error) {
+ logger.error('get account error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:add', async (_event, params: {
+ name: string; email: string; configDir: string;
+ }) => {
+ try {
+ const profile = requireRegistry().add(params);
+ return { success: true, account: profile };
+ } catch (error) {
+ logger.error('add account error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle('accounts:update', async (_event, accountId: string, updates: Record) => {
+ try {
+ const updated = requireRegistry().update(accountId, updates);
+ if (!updated) return { success: false, error: 'Account not found' };
+ return { success: true, account: updated };
+ } catch (error) {
+ logger.error('update account error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle('accounts:remove', async (_event, accountId: string) => {
+ try {
+ const removed = requireRegistry().remove(accountId);
+ return { success: removed, error: removed ? undefined : 'Account not found' };
+ } catch (error) {
+ logger.error('remove account error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle('accounts:set-default', async (_event, accountId: string) => {
+ try {
+ const updated = requireRegistry().update(accountId, { isDefault: true });
+ return { success: !!updated };
+ } catch (error) {
+ logger.error('set default error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ // --- Assignments ---
+
+ ipcMain.handle('accounts:assign', async (_event, sessionId: string, accountId: string) => {
+ try {
+ const assignment = requireRegistry().assignToSession(sessionId, accountId);
+ return { success: true, assignment };
+ } catch (error) {
+ logger.error('assign account error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle('accounts:get-assignment', async (_event, sessionId: string) => {
+ try {
+ return requireRegistry().getAssignment(sessionId);
+ } catch (error) {
+ logger.error('get assignment error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:get-all-assignments', async () => {
+ try {
+ return requireRegistry().getAllAssignments();
+ } catch (error) {
+ logger.error('get all assignments error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
+ // --- Usage Queries ---
+
+ ipcMain.handle('accounts:get-usage', async (_event, accountId: string, windowStart: number, windowEnd: number) => {
+ try {
+ const db = getStatsDB();
+ return db.getAccountUsageInWindow(accountId, windowStart, windowEnd);
+ } catch (error) {
+ logger.error('get usage error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:get-all-usage', async () => {
+ try {
+ const registry = requireRegistry();
+ const db = getStatsDB();
+ const accounts = registry.getAll();
+ const now = Date.now();
+ const results: Record = {};
+
+ for (const account of accounts) {
+ const windowMs = account.tokenWindowMs || 5 * 60 * 60 * 1000;
+ // Align to window boundaries from midnight
+ const dayStart = new Date(now);
+ dayStart.setHours(0, 0, 0, 0);
+ const dayStartMs = dayStart.getTime();
+ const windowsSinceDayStart = Math.floor((now - dayStartMs) / windowMs);
+ const windowStart = dayStartMs + windowsSinceDayStart * windowMs;
+ const windowEnd = windowStart + windowMs;
+
+ const usage = db.getAccountUsageInWindow(account.id, windowStart, windowEnd);
+ const totalTokens = usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheCreationTokens;
+
+ results[account.id] = {
+ ...usage,
+ totalTokens,
+ usagePercent: account.tokenLimitPerWindow > 0
+ ? Math.min(100, (totalTokens / account.tokenLimitPerWindow) * 100)
+ : null,
+ windowStart,
+ windowEnd,
+ account,
+ };
+ }
+ return results;
+ } catch (error) {
+ logger.error('get all usage error', LOG_CONTEXT, { error: String(error) });
+ return {};
+ }
+ });
+
+ ipcMain.handle('accounts:get-throttle-events', async (_event, accountId?: string, since?: number) => {
+ try {
+ const db = getStatsDB();
+ return db.getThrottleEvents(accountId, since);
+ } catch (error) {
+ logger.error('get throttle events error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
+ // --- Switch Configuration ---
+
+ ipcMain.handle('accounts:get-switch-config', async () => {
+ try {
+ return requireRegistry().getSwitchConfig();
+ } catch (error) {
+ logger.error('get switch config error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:update-switch-config', async (_event, updates: Partial) => {
+ try {
+ const updated = requireRegistry().updateSwitchConfig(updates);
+ return { success: true, config: updated };
+ } catch (error) {
+ logger.error('update switch config error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ // --- Account Selection ---
+
+ ipcMain.handle('accounts:get-default', async () => {
+ try {
+ return requireRegistry().getDefaultAccount();
+ } catch (error) {
+ logger.error('get default error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:select-next', async (_event, excludeIds?: string[]) => {
+ try {
+ return requireRegistry().selectNextAccount(excludeIds);
+ } catch (error) {
+ logger.error('select next error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+}
diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts
index d123fc783..96701f6d4 100644
--- a/src/main/ipc/handlers/index.ts
+++ b/src/main/ipc/handlers/index.ts
@@ -52,6 +52,7 @@ import { registerSymphonyHandlers, SymphonyHandlerDependencies } from './symphon
import { registerAgentErrorHandlers } from './agent-error';
import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming';
import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes';
+import { registerAccountHandlers, AccountHandlerDependencies } from './accounts';
import { AgentDetector } from '../../agents';
import { ProcessManager } from '../../process-manager';
import { WebServer } from '../../web-server';
@@ -95,6 +96,8 @@ export { registerTabNamingHandlers };
export type { TabNamingHandlerDependencies };
export { registerDirectorNotesHandlers };
export type { DirectorNotesHandlerDependencies };
+export { registerAccountHandlers };
+export type { AccountHandlerDependencies };
export type { AgentsHandlerDependencies };
export type { ProcessHandlerDependencies };
export type { PersistenceHandlerDependencies };
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
new file mode 100644
index 000000000..014857561
--- /dev/null
+++ b/src/main/preload/accounts.ts
@@ -0,0 +1,155 @@
+/**
+ * Preload API for account multiplexing
+ *
+ * Provides the window.maestro.accounts namespace for:
+ * - Account CRUD operations (list, get, add, update, remove)
+ * - Session-to-account assignments
+ * - Usage queries (windowed token consumption)
+ * - Throttle event queries (capacity planning)
+ * - Switch configuration management
+ * - Account selection (default, next available)
+ * - Real-time account usage updates
+ * - Account limit warnings and reached notifications
+ */
+
+import { ipcRenderer } from 'electron';
+
+/**
+ * Account usage update data broadcast from the account usage listener
+ */
+export interface AccountUsageUpdate {
+ accountId: string;
+ usagePercent: number;
+ totalTokens: number;
+ limitTokens: number;
+ windowStart: number;
+ windowEnd: number;
+ queryCount: number;
+ costUsd: number;
+}
+
+/**
+ * Account limit warning/reached data
+ */
+export interface AccountLimitEvent {
+ accountId: string;
+ accountName: string;
+ usagePercent: number;
+ sessionId: string;
+}
+
+/**
+ * Creates the accounts API object for preload exposure
+ */
+export function createAccountsApi() {
+ return {
+ // --- Account CRUD ---
+
+ /** List all registered accounts */
+ list: (): Promise => ipcRenderer.invoke('accounts:list'),
+
+ /** Get a single account by ID */
+ get: (id: string): Promise => ipcRenderer.invoke('accounts:get', id),
+
+ /** Add a new account */
+ add: (params: { name: string; email: string; configDir: string }): Promise =>
+ ipcRenderer.invoke('accounts:add', params),
+
+ /** Update an existing account */
+ update: (id: string, updates: Record): Promise =>
+ ipcRenderer.invoke('accounts:update', id, updates),
+
+ /** Remove an account */
+ remove: (id: string): Promise => ipcRenderer.invoke('accounts:remove', id),
+
+ /** Set an account as the default */
+ setDefault: (id: string): Promise => ipcRenderer.invoke('accounts:set-default', id),
+
+ // --- Assignments ---
+
+ /** Assign an account to a session */
+ assign: (sessionId: string, accountId: string): Promise =>
+ ipcRenderer.invoke('accounts:assign', sessionId, accountId),
+
+ /** Get the account assigned to a session */
+ getAssignment: (sessionId: string): Promise =>
+ ipcRenderer.invoke('accounts:get-assignment', sessionId),
+
+ /** Get all current session-to-account assignments */
+ getAllAssignments: (): Promise => ipcRenderer.invoke('accounts:get-all-assignments'),
+
+ // --- Usage Queries ---
+
+ /** Get usage for an account within a specific time window */
+ getUsage: (accountId: string, windowStart: number, windowEnd: number): Promise =>
+ ipcRenderer.invoke('accounts:get-usage', accountId, windowStart, windowEnd),
+
+ /** Get usage for all accounts in their current windows */
+ getAllUsage: (): Promise => ipcRenderer.invoke('accounts:get-all-usage'),
+
+ /** Get throttle events for capacity planning */
+ getThrottleEvents: (accountId?: string, since?: number): Promise =>
+ ipcRenderer.invoke('accounts:get-throttle-events', accountId, since),
+
+ // --- Switch Configuration ---
+
+ /** Get the current account switching configuration */
+ getSwitchConfig: (): Promise => ipcRenderer.invoke('accounts:get-switch-config'),
+
+ /** Update account switching configuration */
+ updateSwitchConfig: (updates: Record): Promise =>
+ ipcRenderer.invoke('accounts:update-switch-config', updates),
+
+ // --- Account Selection ---
+
+ /** Get the default account */
+ getDefault: (): Promise => ipcRenderer.invoke('accounts:get-default'),
+
+ /** Select the next available account (for auto-switching) */
+ selectNext: (excludeIds?: string[]): Promise =>
+ ipcRenderer.invoke('accounts:select-next', excludeIds),
+
+ // --- Event Listeners ---
+
+ /**
+ * Subscribe to real-time account usage updates
+ * @param handler - Callback with usage data
+ * @returns Cleanup function to unsubscribe
+ */
+ onUsageUpdate: (handler: (data: AccountUsageUpdate) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: AccountUsageUpdate) =>
+ handler(data);
+ ipcRenderer.on('account:usage-update', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:usage-update', wrappedHandler);
+ },
+
+ /**
+ * Subscribe to account limit warning events (usage approaching threshold)
+ * @param handler - Callback with limit event data
+ * @returns Cleanup function to unsubscribe
+ */
+ onLimitWarning: (handler: (data: AccountLimitEvent) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: AccountLimitEvent) =>
+ handler(data);
+ ipcRenderer.on('account:limit-warning', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:limit-warning', wrappedHandler);
+ },
+
+ /**
+ * Subscribe to account limit reached events (auto-switch threshold exceeded)
+ * @param handler - Callback with limit event data
+ * @returns Cleanup function to unsubscribe
+ */
+ onLimitReached: (handler: (data: AccountLimitEvent) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: AccountLimitEvent) =>
+ handler(data);
+ ipcRenderer.on('account:limit-reached', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:limit-reached', wrappedHandler);
+ },
+ };
+}
+
+/**
+ * TypeScript type for the accounts API
+ */
+export type AccountsApi = ReturnType;
diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts
index 7505186c1..e3b8f5791 100644
--- a/src/main/preload/index.ts
+++ b/src/main/preload/index.ts
@@ -49,6 +49,7 @@ import { createAgentsApi } from './agents';
import { createSymphonyApi } from './symphony';
import { createTabNamingApi } from './tabNaming';
import { createDirectorNotesApi } from './directorNotes';
+import { createAccountsApi } from './accounts';
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
@@ -184,6 +185,9 @@ contextBridge.exposeInMainWorld('maestro', {
// Director's Notes API (unified history + synopsis)
directorNotes: createDirectorNotesApi(),
+
+ // Account Multiplexing API (usage events, limit warnings)
+ accounts: createAccountsApi(),
});
// Re-export factory functions for external consumers (e.g., tests)
@@ -255,6 +259,8 @@ export {
createTabNamingApi,
// Director's Notes
createDirectorNotesApi,
+ // Accounts
+ createAccountsApi,
};
// Re-export types for TypeScript consumers
@@ -459,3 +465,9 @@ export type {
SynopsisResult,
SynopsisStats,
} from './directorNotes';
+export type {
+ // From accounts
+ AccountsApi,
+ AccountUsageUpdate,
+ AccountLimitEvent,
+} from './accounts';
diff --git a/src/main/process-listeners/account-usage-listener.ts b/src/main/process-listeners/account-usage-listener.ts
new file mode 100644
index 000000000..1dc2313ca
--- /dev/null
+++ b/src/main/process-listeners/account-usage-listener.ts
@@ -0,0 +1,128 @@
+/**
+ * Account usage listener.
+ * Aggregates per-session usage events into per-account usage windows
+ * for limit tracking and prediction.
+ */
+
+import type { ProcessManager } from '../process-manager';
+import type { AccountRegistry } from '../accounts/account-registry';
+import type { StatsDB } from '../stats';
+import type { UsageStats } from './types';
+import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types';
+
+const LOG_CONTEXT = 'account-usage-listener';
+
+/**
+ * Calculate the window boundaries for a given timestamp and window size.
+ * Windows are aligned to fixed intervals (e.g., every 5 hours from midnight).
+ */
+function getWindowBounds(timestamp: number, windowMs: number): { start: number; end: number } {
+ // Align windows to midnight of the current day
+ const dayStart = new Date(timestamp);
+ dayStart.setHours(0, 0, 0, 0);
+ const dayStartMs = dayStart.getTime();
+
+ const windowsSinceDayStart = Math.floor((timestamp - dayStartMs) / windowMs);
+ const start = dayStartMs + windowsSinceDayStart * windowMs;
+ const end = start + windowMs;
+ return { start, end };
+}
+
+/**
+ * Sets up the account usage listener that aggregates per-session usage events
+ * into per-account usage windows for limit tracking and prediction.
+ *
+ * Only fires when usage events occur for sessions with account assignments,
+ * so it has zero impact on sessions without accounts.
+ */
+export function setupAccountUsageListener(
+ processManager: ProcessManager,
+ deps: {
+ getAccountRegistry: () => AccountRegistry | null;
+ getStatsDB: () => StatsDB;
+ safeSend: (channel: string, ...args: unknown[]) => void;
+ logger: {
+ error: (message: string, context: string, data?: Record) => void;
+ debug: (message: string, context: string, data?: Record) => void;
+ };
+ }
+): void {
+ const { getAccountRegistry, getStatsDB, safeSend, logger } = deps;
+
+ processManager.on('usage', (sessionId: string, usageStats: UsageStats) => {
+ try {
+ const accountRegistry = getAccountRegistry();
+ if (!accountRegistry) return; // Account system not initialized
+
+ // Look up the account assigned to this session
+ const assignment = accountRegistry.getAssignment(sessionId);
+ if (!assignment) return; // No account assigned — skip
+
+ const account = accountRegistry.get(assignment.accountId);
+ if (!account) return; // Account was deleted — skip
+
+ const statsDb = getStatsDB();
+ if (!statsDb.isReady()) return; // Stats DB not ready
+
+ const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS;
+ const now = Date.now();
+ const { start, end } = getWindowBounds(now, windowMs);
+
+ // Aggregate tokens into the account's current window
+ statsDb.upsertAccountUsageWindow(account.id, start, end, {
+ inputTokens: usageStats.inputTokens || 0,
+ outputTokens: usageStats.outputTokens || 0,
+ cacheReadTokens: usageStats.cacheReadInputTokens || 0,
+ cacheCreationTokens: usageStats.cacheCreationInputTokens || 0,
+ costUsd: usageStats.totalCostUsd || 0,
+ });
+
+ // Calculate usage percentage if limit is configured
+ if (account.tokenLimitPerWindow > 0) {
+ const windowUsage = statsDb.getAccountUsageInWindow(account.id, start, end);
+ const totalTokens = windowUsage.inputTokens + windowUsage.outputTokens
+ + windowUsage.cacheReadTokens + windowUsage.cacheCreationTokens;
+ const usagePercent = Math.min(100, (totalTokens / account.tokenLimitPerWindow) * 100);
+
+ // Broadcast usage update to renderer for real-time dashboard
+ safeSend('account:usage-update', {
+ accountId: account.id,
+ usagePercent,
+ totalTokens,
+ limitTokens: account.tokenLimitPerWindow,
+ windowStart: start,
+ windowEnd: end,
+ queryCount: windowUsage.queryCount,
+ costUsd: windowUsage.costUsd,
+ });
+
+ // Check warning threshold
+ const switchConfig = accountRegistry.getSwitchConfig();
+ if (usagePercent >= switchConfig.warningThresholdPercent && usagePercent < switchConfig.autoSwitchThresholdPercent) {
+ safeSend('account:limit-warning', {
+ accountId: account.id,
+ accountName: account.name,
+ usagePercent,
+ sessionId,
+ });
+ }
+
+ // Check auto-switch threshold
+ if (usagePercent >= switchConfig.autoSwitchThresholdPercent) {
+ safeSend('account:limit-reached', {
+ accountId: account.id,
+ accountName: account.name,
+ usagePercent,
+ sessionId,
+ });
+ }
+ }
+
+ // Update the account's lastUsedAt
+ accountRegistry.touchLastUsed(account.id);
+
+ } catch (error) {
+ logger.error('Failed to track account usage', LOG_CONTEXT, { error: String(error), sessionId });
+ }
+ });
+}
diff --git a/src/main/process-listeners/index.ts b/src/main/process-listeners/index.ts
index 06882f3df..e258e451b 100644
--- a/src/main/process-listeners/index.ts
+++ b/src/main/process-listeners/index.ts
@@ -17,6 +17,7 @@ import { setupSessionIdListener } from './session-id-listener';
import { setupErrorListener } from './error-listener';
import { setupStatsListener } from './stats-listener';
import { setupExitListener } from './exit-listener';
+import { setupAccountUsageListener } from './account-usage-listener';
// Re-export types for consumers
export type { ProcessListenerDependencies, ParticipantInfo } from './types';
@@ -52,4 +53,14 @@ export function setupProcessListeners(
// Exit listener (with group chat routing, recovery, and synthesis)
setupExitListener(processManager, deps);
+
+ // Account usage listener (per-account token aggregation for limit tracking)
+ if (deps.getAccountRegistry) {
+ setupAccountUsageListener(processManager, {
+ getAccountRegistry: deps.getAccountRegistry,
+ getStatsDB: deps.getStatsDB,
+ safeSend: deps.safeSend,
+ logger: deps.logger,
+ });
+ }
}
diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts
index bb6a0a252..a0d3e91f1 100644
--- a/src/main/process-listeners/types.ts
+++ b/src/main/process-listeners/types.ts
@@ -8,6 +8,7 @@ import type { WebServer } from '../web-server';
import type { AgentDetector } from '../agents';
import type { SafeSendFn } from '../utils/safe-send';
import type { StatsDB } from '../stats';
+import type { AccountRegistry } from '../accounts/account-registry';
import type { GroupChat, GroupChatParticipant } from '../group-chat/group-chat-storage';
import type { GroupChatMessage, GroupChatState } from '../../shared/group-chat-types';
import type { ParticipantState } from '../ipc/handlers/groupChat';
@@ -143,6 +144,8 @@ export interface ProcessListenerDependencies {
};
/** Stats database getter */
getStatsDB: () => StatsDB;
+ /** Account registry getter (optional — only needed for account multiplexing) */
+ getAccountRegistry?: () => AccountRegistry | null;
/** Debug log function */
debugLog: (prefix: string, message: string, ...args: unknown[]) => void;
/** Regex patterns */
From 769543a93f5c9a0e368a08cc627e48195e66a4ac Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 09:28:06 -0500
Subject: [PATCH 05/59] MAESTRO: feat: add account directory setup service and
login orchestration
Adds account-setup.ts with directory creation, symlink management,
account discovery, email extraction, and SSH remote validation.
Registers 9 IPC handlers and preload bridge methods for the full
account setup lifecycle. Includes 25 unit tests.
Co-Authored-By: Claude Opus 4.6
---
.../main/accounts/account-setup.test.ts | 424 ++++++++++++++++++
src/main/accounts/account-setup.ts | 332 ++++++++++++++
src/main/ipc/handlers/accounts.ts | 97 ++++
src/main/preload/accounts.ts | 38 ++
4 files changed, 891 insertions(+)
create mode 100644 src/__tests__/main/accounts/account-setup.test.ts
create mode 100644 src/main/accounts/account-setup.ts
diff --git a/src/__tests__/main/accounts/account-setup.test.ts b/src/__tests__/main/accounts/account-setup.test.ts
new file mode 100644
index 000000000..eddb5b14d
--- /dev/null
+++ b/src/__tests__/main/accounts/account-setup.test.ts
@@ -0,0 +1,424 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as path from 'path';
+
+// Hoist mock functions so they can be used in vi.mock factories
+const {
+ TEST_HOME,
+ mockStat, mockAccess, mockReadFile, mockReaddir,
+ mockMkdir, mockLstat, mockSymlink, mockUnlink, mockRm,
+ mockExecFile,
+} = vi.hoisted(() => ({
+ TEST_HOME: '/home/testuser',
+ mockStat: vi.fn(),
+ mockAccess: vi.fn(),
+ mockReadFile: vi.fn(),
+ mockReaddir: vi.fn(),
+ mockMkdir: vi.fn(),
+ mockLstat: vi.fn(),
+ mockSymlink: vi.fn(),
+ mockUnlink: vi.fn(),
+ mockRm: vi.fn(),
+ mockExecFile: vi.fn(),
+}));
+
+// Mock fs/promises module
+vi.mock('fs/promises', () => ({
+ default: {
+ stat: mockStat,
+ access: mockAccess,
+ readFile: mockReadFile,
+ readdir: mockReaddir,
+ mkdir: mockMkdir,
+ lstat: mockLstat,
+ symlink: mockSymlink,
+ unlink: mockUnlink,
+ rm: mockRm,
+ },
+ stat: mockStat,
+ access: mockAccess,
+ readFile: mockReadFile,
+ readdir: mockReaddir,
+ mkdir: mockMkdir,
+ lstat: mockLstat,
+ symlink: mockSymlink,
+ unlink: mockUnlink,
+ rm: mockRm,
+}));
+
+// Mock os module
+vi.mock('os', () => ({
+ default: {
+ homedir: vi.fn().mockReturnValue(TEST_HOME),
+ },
+ homedir: vi.fn().mockReturnValue(TEST_HOME),
+}));
+
+// Mock logger
+vi.mock('../../../main/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+// Mock child_process for SSH validation
+vi.mock('child_process', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ execFile: mockExecFile,
+ },
+ execFile: mockExecFile,
+ };
+});
+
+// Mock util.promisify for execFile
+vi.mock('util', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ promisify: (fn: any) => {
+ if (fn === mockExecFile) {
+ return async (...args: any[]) => {
+ return new Promise((resolve, reject) => {
+ mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => {
+ if (error) reject(error);
+ else resolve({ stdout, stderr });
+ });
+ });
+ };
+ }
+ return actual.promisify(fn);
+ },
+ },
+ promisify: (fn: any) => {
+ if (fn === mockExecFile) {
+ return async (...args: any[]) => {
+ return new Promise((resolve, reject) => {
+ mockExecFile(...args, (error: Error | null, stdout: string, stderr: string) => {
+ if (error) reject(error);
+ else resolve({ stdout, stderr });
+ });
+ });
+ };
+ }
+ return actual.promisify(fn);
+ },
+ };
+});
+
+import {
+ validateBaseClaudeDir,
+ discoverExistingAccounts,
+ readAccountEmail,
+ createAccountDirectory,
+ validateAccountSymlinks,
+ repairAccountSymlinks,
+ buildLoginCommand,
+ removeAccountDirectory,
+ validateRemoteAccountDir,
+} from '../../../main/accounts/account-setup';
+
+describe('account-setup', () => {
+ const baseDir = path.join(TEST_HOME, '.claude');
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('validateBaseClaudeDir', () => {
+ it('should return valid when .claude dir and .claude.json exist', async () => {
+ mockStat.mockResolvedValue({ isDirectory: () => true });
+ mockAccess.mockResolvedValue(undefined);
+
+ const result = await validateBaseClaudeDir();
+ expect(result.valid).toBe(true);
+ expect(result.baseDir).toBe(baseDir);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('should return errors when .claude dir does not exist', async () => {
+ mockStat.mockRejectedValue(new Error('ENOENT'));
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
+
+ const result = await validateBaseClaudeDir();
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(0);
+ expect(result.errors[0]).toContain('does not exist');
+ });
+
+ it('should report missing .claude.json', async () => {
+ mockStat.mockResolvedValue({ isDirectory: () => true });
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
+
+ const result = await validateBaseClaudeDir();
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain('No .claude.json found — Claude Code may not be authenticated.');
+ });
+ });
+
+ describe('readAccountEmail', () => {
+ it('should extract email from .claude.json', async () => {
+ mockReadFile.mockResolvedValue(JSON.stringify({ email: 'user@example.com' }));
+ const email = await readAccountEmail('/fake/.claude-test');
+ expect(email).toBe('user@example.com');
+ });
+
+ it('should try alternative field names', async () => {
+ mockReadFile.mockResolvedValue(JSON.stringify({ accountEmail: 'alt@example.com' }));
+ const email = await readAccountEmail('/fake/.claude-test');
+ expect(email).toBe('alt@example.com');
+ });
+
+ it('should return null for unreadable file', async () => {
+ mockReadFile.mockRejectedValue(new Error('ENOENT'));
+ const email = await readAccountEmail('/fake/.claude-test');
+ expect(email).toBeNull();
+ });
+
+ it('should return null for invalid JSON', async () => {
+ mockReadFile.mockResolvedValue('not-json');
+ const email = await readAccountEmail('/fake/.claude-test');
+ expect(email).toBeNull();
+ });
+
+ it('should extract nested email from oauthAccount', async () => {
+ mockReadFile.mockResolvedValue(JSON.stringify({
+ oauthAccount: { email: 'nested@example.com' },
+ }));
+ const email = await readAccountEmail('/fake/.claude-test');
+ expect(email).toBe('nested@example.com');
+ });
+ });
+
+ describe('buildLoginCommand', () => {
+ it('should build command with default binary', () => {
+ const cmd = buildLoginCommand('/home/user/.claude-work');
+ expect(cmd).toBe('CLAUDE_CONFIG_DIR="/home/user/.claude-work" claude login');
+ });
+
+ it('should build command with custom binary path', () => {
+ const cmd = buildLoginCommand('/home/user/.claude-work', '/usr/local/bin/claude');
+ expect(cmd).toBe('CLAUDE_CONFIG_DIR="/home/user/.claude-work" /usr/local/bin/claude login');
+ });
+ });
+
+ describe('createAccountDirectory', () => {
+ it('should fail if directory already exists', async () => {
+ mockAccess.mockResolvedValue(undefined);
+
+ const result = await createAccountDirectory('test');
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('already exists');
+ });
+
+ it('should fail if base dir validation fails', async () => {
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
+ mockStat.mockRejectedValue(new Error('ENOENT'));
+
+ const result = await createAccountDirectory('test');
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('does not exist');
+ });
+
+ it('should create directory and symlinks when base dir is valid', async () => {
+ mockAccess.mockImplementation(async (p: string) => {
+ const pStr = String(p);
+ if (pStr.endsWith('.claude-newacct')) {
+ throw new Error('ENOENT');
+ }
+ return undefined;
+ });
+ mockStat.mockResolvedValue({ isDirectory: () => true });
+ mockMkdir.mockResolvedValue(undefined);
+ mockLstat.mockRejectedValue(new Error('ENOENT'));
+ mockSymlink.mockResolvedValue(undefined);
+
+ const result = await createAccountDirectory('newacct');
+ expect(result.success).toBe(true);
+ expect(result.configDir).toBe(path.join(TEST_HOME, '.claude-newacct'));
+ expect(mockMkdir).toHaveBeenCalled();
+ expect(mockSymlink).toHaveBeenCalled();
+ });
+ });
+
+ describe('validateAccountSymlinks', () => {
+ it('should report valid when all symlinks are intact', async () => {
+ mockLstat.mockResolvedValue({ isSymbolicLink: () => true });
+ mockStat.mockResolvedValue({});
+
+ const result = await validateAccountSymlinks('/fake/.claude-test');
+ expect(result.valid).toBe(true);
+ expect(result.broken).toHaveLength(0);
+ expect(result.missing).toHaveLength(0);
+ });
+
+ it('should report broken symlinks', async () => {
+ mockLstat.mockResolvedValue({ isSymbolicLink: () => true });
+ mockStat.mockRejectedValue(new Error('ENOENT'));
+
+ const result = await validateAccountSymlinks('/fake/.claude-test');
+ expect(result.valid).toBe(false);
+ expect(result.broken.length).toBeGreaterThan(0);
+ });
+
+ it('should report missing symlinks when source exists', async () => {
+ mockLstat.mockRejectedValue(new Error('ENOENT'));
+ mockAccess.mockResolvedValue(undefined);
+
+ const result = await validateAccountSymlinks('/fake/.claude-test');
+ expect(result.valid).toBe(false);
+ expect(result.missing.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('removeAccountDirectory', () => {
+ it('should reject non-.claude- directories', async () => {
+ const result = await removeAccountDirectory('/home/user/important-stuff');
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Safety check');
+ });
+
+ it('should remove valid .claude- directories', async () => {
+ mockRm.mockResolvedValue(undefined);
+
+ const result = await removeAccountDirectory(path.join(TEST_HOME, '.claude-test'));
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle rm errors gracefully', async () => {
+ mockRm.mockRejectedValue(new Error('Permission denied'));
+
+ const result = await removeAccountDirectory(path.join(TEST_HOME, '.claude-test'));
+ expect(result.success).toBe(false);
+ expect(result.error).toContain('Permission denied');
+ });
+ });
+
+ describe('discoverExistingAccounts', () => {
+ it('should find .claude-* directories', async () => {
+ mockReaddir.mockResolvedValue([
+ { name: '.claude-work', isDirectory: () => true, isSymbolicLink: () => false },
+ { name: '.claude-personal', isDirectory: () => true, isSymbolicLink: () => false },
+ { name: '.bashrc', isDirectory: () => false, isSymbolicLink: () => false },
+ { name: 'Documents', isDirectory: () => true, isSymbolicLink: () => false },
+ ]);
+
+ mockReadFile.mockImplementation(async (p: string) => {
+ if (String(p).includes('.claude-work')) {
+ return JSON.stringify({ email: 'work@example.com' });
+ }
+ throw new Error('ENOENT');
+ });
+
+ const accounts = await discoverExistingAccounts();
+ expect(accounts).toHaveLength(2);
+ expect(accounts[0].name).toBe('work');
+ expect(accounts[0].email).toBe('work@example.com');
+ expect(accounts[0].hasAuth).toBe(true);
+ expect(accounts[1].name).toBe('personal');
+ expect(accounts[1].email).toBeNull();
+ expect(accounts[1].hasAuth).toBe(false);
+ });
+ });
+
+ describe('repairAccountSymlinks', () => {
+ it('should repair broken and missing symlinks', async () => {
+ mockLstat.mockImplementation(async (p: string) => {
+ const pStr = String(p);
+ if (pStr.endsWith('/commands')) {
+ return { isSymbolicLink: () => true };
+ }
+ throw new Error('ENOENT');
+ });
+ mockStat.mockRejectedValue(new Error('ENOENT'));
+ mockAccess.mockResolvedValue(undefined);
+ mockUnlink.mockResolvedValue(undefined);
+ mockSymlink.mockResolvedValue(undefined);
+
+ const result = await repairAccountSymlinks('/fake/.claude-test');
+ expect(result.errors).toHaveLength(0);
+ expect(result.repaired.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('validateRemoteAccountDir', () => {
+ it('should validate existing remote directory', async () => {
+ mockExecFile.mockImplementation(
+ (_cmd: string, args: string[], _opts: any, callback: any) => {
+ const command = args[args.length - 1];
+ if (command.includes('DIR_EXISTS')) {
+ callback(null, 'DIR_EXISTS\n', '');
+ } else if (command.includes('AUTH_EXISTS')) {
+ callback(null, 'AUTH_EXISTS\n', '');
+ } else if (command.includes('SYMLINKS_OK')) {
+ callback(null, 'SYMLINKS_OK\n', '');
+ }
+ },
+ );
+
+ const result = await validateRemoteAccountDir(
+ { host: 'example.com', user: 'dev' },
+ '~/.claude-work',
+ );
+
+ expect(result.exists).toBe(true);
+ expect(result.hasAuth).toBe(true);
+ expect(result.symlinksValid).toBe(true);
+ });
+
+ it('should detect missing remote directory', async () => {
+ mockExecFile.mockImplementation(
+ (_cmd: string, _args: string[], _opts: any, callback: any) => {
+ callback(null, 'DIR_MISSING\n', '');
+ },
+ );
+
+ const result = await validateRemoteAccountDir(
+ { host: 'example.com' },
+ '~/.claude-work',
+ );
+
+ expect(result.exists).toBe(false);
+ expect(result.hasAuth).toBe(false);
+ expect(result.symlinksValid).toBe(false);
+ });
+
+ it('should handle SSH connection errors', async () => {
+ mockExecFile.mockImplementation(
+ (_cmd: string, _args: string[], _opts: any, callback: any) => {
+ callback(new Error('Connection refused'), '', '');
+ },
+ );
+
+ const result = await validateRemoteAccountDir(
+ { host: 'example.com', user: 'dev', port: 2222 },
+ '~/.claude-work',
+ );
+
+ expect(result.exists).toBe(false);
+ expect(result.error).toContain('Connection refused');
+ });
+
+ it('should include port in SSH args', async () => {
+ mockExecFile.mockImplementation(
+ (_cmd: string, args: string[], _opts: any, callback: any) => {
+ expect(args).toContain('-p');
+ expect(args).toContain('2222');
+ callback(null, 'DIR_MISSING\n', '');
+ },
+ );
+
+ await validateRemoteAccountDir(
+ { host: 'example.com', port: 2222 },
+ '~/.claude-work',
+ );
+ });
+ });
+});
diff --git a/src/main/accounts/account-setup.ts b/src/main/accounts/account-setup.ts
new file mode 100644
index 000000000..31c4dd360
--- /dev/null
+++ b/src/main/accounts/account-setup.ts
@@ -0,0 +1,332 @@
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import * as os from 'os';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'account-setup';
+const execFileAsync = promisify(execFile);
+
+/** Resources that are symlinked from ~/.claude to each account directory */
+const SHARED_SYMLINKS = [
+ 'commands',
+ 'ide',
+ 'plans',
+ 'plugins',
+ 'settings.json',
+ 'CLAUDE.md',
+ 'todos',
+ 'session-env',
+ 'projects',
+];
+
+/**
+ * Validate that the base ~/.claude directory exists and has the expected structure.
+ */
+export async function validateBaseClaudeDir(): Promise<{
+ valid: boolean;
+ baseDir: string;
+ errors: string[];
+}> {
+ const baseDir = path.join(os.homedir(), '.claude');
+ const errors: string[] = [];
+
+ try {
+ const stat = await fs.stat(baseDir);
+ if (!stat.isDirectory()) {
+ errors.push(`${baseDir} exists but is not a directory`);
+ }
+ } catch {
+ errors.push(`${baseDir} does not exist. Run 'claude' at least once to create it.`);
+ }
+
+ // Check for .claude.json (auth tokens)
+ try {
+ await fs.access(path.join(baseDir, '.claude.json'));
+ } catch {
+ errors.push('No .claude.json found — Claude Code may not be authenticated.');
+ }
+
+ return { valid: errors.length === 0, baseDir, errors };
+}
+
+/**
+ * Discover existing Claude account directories by scanning for ~/.claude-* directories
+ * that contain a .claude.json file.
+ */
+export async function discoverExistingAccounts(): Promise> {
+ const homeDir = os.homedir();
+ const entries = await fs.readdir(homeDir, { withFileTypes: true });
+ const accounts: Array<{ configDir: string; name: string; email: string | null; hasAuth: boolean }> = [];
+
+ for (const entry of entries) {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
+ if (!entry.name.startsWith('.claude-')) continue;
+
+ const configDir = path.join(homeDir, entry.name);
+ const name = entry.name.replace('.claude-', '');
+
+ // Check if it has auth tokens
+ let hasAuth = false;
+ let email: string | null = null;
+ try {
+ const authFile = path.join(configDir, '.claude.json');
+ const content = await fs.readFile(authFile, 'utf-8');
+ hasAuth = true;
+ email = extractEmailFromClaudeJson(content);
+ } catch {
+ // No auth file or unreadable
+ }
+
+ accounts.push({ configDir, name, email, hasAuth });
+ }
+
+ return accounts;
+}
+
+/**
+ * Extract the email address from a .claude.json file content.
+ * The structure may vary — look for common fields like "email", "accountEmail", etc.
+ */
+function extractEmailFromClaudeJson(content: string): string | null {
+ try {
+ const json = JSON.parse(content);
+ // Try common field names where email might be stored
+ return json.email
+ || json.accountEmail
+ || json.primaryEmail
+ || json.oauthAccount?.email
+ || json.account?.email
+ || null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Read the email identity from an account's .claude.json file.
+ */
+export async function readAccountEmail(configDir: string): Promise {
+ try {
+ const authFile = path.join(configDir, '.claude.json');
+ const content = await fs.readFile(authFile, 'utf-8');
+ return extractEmailFromClaudeJson(content);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Create a new Claude account directory with symlinks to shared resources.
+ * Does NOT authenticate — that requires running `claude login` separately.
+ */
+export async function createAccountDirectory(accountName: string): Promise<{
+ success: boolean;
+ configDir: string;
+ error?: string;
+}> {
+ const homeDir = os.homedir();
+ const baseDir = path.join(homeDir, '.claude');
+ const configDir = path.join(homeDir, `.claude-${accountName}`);
+
+ try {
+ // Check if directory already exists
+ try {
+ await fs.access(configDir);
+ return { success: false, configDir, error: `Directory ${configDir} already exists` };
+ } catch {
+ // Good — doesn't exist yet
+ }
+
+ // Validate base directory
+ const validation = await validateBaseClaudeDir();
+ if (!validation.valid) {
+ return { success: false, configDir, error: validation.errors.join('; ') };
+ }
+
+ // Create the account directory
+ await fs.mkdir(configDir, { recursive: true });
+ logger.info(`Created account directory: ${configDir}`, LOG_CONTEXT);
+
+ // Create symlinks for shared resources
+ for (const resource of SHARED_SYMLINKS) {
+ const source = path.join(baseDir, resource);
+ const target = path.join(configDir, resource);
+
+ try {
+ await fs.access(source);
+ // Check if target already exists
+ try {
+ await fs.lstat(target);
+ // Already exists (maybe from a previous attempt) — skip
+ continue;
+ } catch {
+ // Doesn't exist — create symlink
+ }
+ await fs.symlink(source, target);
+ logger.info(`Symlinked ${resource}`, LOG_CONTEXT);
+ } catch {
+ // Source doesn't exist — not all resources are required
+ logger.warn(`Skipped symlink for ${resource} (source not found)`, LOG_CONTEXT);
+ }
+ }
+
+ return { success: true, configDir };
+ } catch (error) {
+ logger.error('Failed to create account directory', LOG_CONTEXT, { error: String(error) });
+ return { success: false, configDir, error: String(error) };
+ }
+}
+
+/**
+ * Validate an account directory's symlinks are intact.
+ * Returns list of broken or missing symlinks.
+ */
+export async function validateAccountSymlinks(configDir: string): Promise<{
+ valid: boolean;
+ broken: string[];
+ missing: string[];
+}> {
+ const baseDir = path.join(os.homedir(), '.claude');
+ const broken: string[] = [];
+ const missing: string[] = [];
+
+ for (const resource of SHARED_SYMLINKS) {
+ const target = path.join(configDir, resource);
+ try {
+ const stat = await fs.lstat(target);
+ if (stat.isSymbolicLink()) {
+ // Check if symlink target exists
+ try {
+ await fs.stat(target); // follows symlink
+ } catch {
+ broken.push(resource);
+ }
+ }
+ // Not a symlink — could be a real file/dir, which is fine
+ } catch {
+ // Missing entirely — check if source exists
+ try {
+ await fs.access(path.join(baseDir, resource));
+ missing.push(resource);
+ } catch {
+ // Source also doesn't exist — OK, resource is optional
+ }
+ }
+ }
+
+ return { valid: broken.length === 0 && missing.length === 0, broken, missing };
+}
+
+/**
+ * Repair broken or missing symlinks for an account directory.
+ */
+export async function repairAccountSymlinks(configDir: string): Promise<{
+ repaired: string[];
+ errors: string[];
+}> {
+ const baseDir = path.join(os.homedir(), '.claude');
+ const { broken, missing } = await validateAccountSymlinks(configDir);
+ const repaired: string[] = [];
+ const errors: string[] = [];
+
+ for (const resource of [...broken, ...missing]) {
+ const source = path.join(baseDir, resource);
+ const target = path.join(configDir, resource);
+ try {
+ // Remove broken symlink if exists
+ try { await fs.unlink(target); } catch { /* didn't exist */ }
+ await fs.symlink(source, target);
+ repaired.push(resource);
+ } catch (err) {
+ errors.push(`Failed to repair ${resource}: ${err}`);
+ }
+ }
+
+ return { repaired, errors };
+}
+
+/**
+ * Build the command string to launch `claude login` for a specific account.
+ * This should be run in a Maestro terminal session.
+ */
+export function buildLoginCommand(configDir: string, claudeBinaryPath?: string): string {
+ const binary = claudeBinaryPath || 'claude';
+ return `CLAUDE_CONFIG_DIR="${configDir}" ${binary} login`;
+}
+
+/**
+ * Remove an account directory. Does NOT remove symlink targets (shared resources).
+ * Only removes the account-specific directory and its contents.
+ */
+export async function removeAccountDirectory(configDir: string): Promise<{
+ success: boolean;
+ error?: string;
+}> {
+ try {
+ // Safety check: only remove directories matching ~/.claude-* pattern
+ const basename = path.basename(configDir);
+ if (!basename.startsWith('.claude-')) {
+ return { success: false, error: 'Safety check failed: directory name must start with .claude-' };
+ }
+
+ await fs.rm(configDir, { recursive: true, force: true });
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+}
+
+/**
+ * Validate that an account directory exists on a remote host.
+ * Uses SSH to check directory existence and symlink integrity.
+ * Called before spawning an SSH session with a specific account.
+ *
+ * @param sshConfig - The SSH remote config from the session
+ * @param configDir - The CLAUDE_CONFIG_DIR path (e.g., ~/.claude-work)
+ * @returns Validation result with details about remote directory state
+ */
+export async function validateRemoteAccountDir(
+ sshConfig: { host: string; user?: string; port?: number },
+ configDir: string,
+): Promise<{
+ exists: boolean;
+ hasAuth: boolean;
+ symlinksValid: boolean;
+ error?: string;
+}> {
+ const sshTarget = sshConfig.user ? `${sshConfig.user}@${sshConfig.host}` : sshConfig.host;
+ const sshArgs: string[] = [];
+ if (sshConfig.port) sshArgs.push('-p', String(sshConfig.port));
+ sshArgs.push(sshTarget);
+
+ try {
+ // Check directory exists
+ const checkCmd = `test -d "${configDir}" && echo "DIR_EXISTS" || echo "DIR_MISSING"`;
+ const { stdout: dirCheck } = await execFileAsync('ssh', [...sshArgs, checkCmd], { timeout: 10000 });
+
+ if (dirCheck.trim() === 'DIR_MISSING') {
+ return { exists: false, hasAuth: false, symlinksValid: false };
+ }
+
+ // Check .claude.json exists (auth)
+ const authCmd = `test -f "${configDir}/.claude.json" && echo "AUTH_EXISTS" || echo "AUTH_MISSING"`;
+ const { stdout: authCheck } = await execFileAsync('ssh', [...sshArgs, authCmd], { timeout: 10000 });
+ const hasAuth = authCheck.trim() === 'AUTH_EXISTS';
+
+ // Check symlinks (projects/ is the critical one for --resume)
+ const symlinkCmd = `test -L "${configDir}/projects" && test -d "${configDir}/projects" && echo "SYMLINKS_OK" || echo "SYMLINKS_BROKEN"`;
+ const { stdout: symlinkCheck } = await execFileAsync('ssh', [...sshArgs, symlinkCmd], { timeout: 10000 });
+ const symlinksValid = symlinkCheck.trim() === 'SYMLINKS_OK';
+
+ return { exists: true, hasAuth, symlinksValid };
+ } catch (error) {
+ return { exists: false, hasAuth: false, symlinksValid: false, error: String(error) };
+ }
+}
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index 81c772306..56e12850b 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -15,6 +15,17 @@ import type { AccountRegistry } from '../../accounts/account-registry';
import type { AccountSwitchConfig } from '../../../shared/account-types';
import { getStatsDB } from '../../stats';
import { logger } from '../../utils/logger';
+import {
+ validateBaseClaudeDir,
+ discoverExistingAccounts,
+ createAccountDirectory,
+ validateAccountSymlinks,
+ repairAccountSymlinks,
+ readAccountEmail,
+ buildLoginCommand,
+ removeAccountDirectory,
+ validateRemoteAccountDir,
+} from '../../accounts/account-setup';
const LOG_CONTEXT = '[Accounts]';
@@ -234,4 +245,90 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
return null;
}
});
+
+ // --- Account Setup ---
+
+ ipcMain.handle('accounts:validate-base-dir', async () => {
+ try {
+ return await validateBaseClaudeDir();
+ } catch (error) {
+ logger.error('validate base dir error', LOG_CONTEXT, { error: String(error) });
+ return { valid: false, baseDir: '', errors: [String(error)] };
+ }
+ });
+
+ ipcMain.handle('accounts:discover-existing', async () => {
+ try {
+ return await discoverExistingAccounts();
+ } catch (error) {
+ logger.error('discover accounts error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
+ ipcMain.handle('accounts:create-directory', async (_event, accountName: string) => {
+ try {
+ return await createAccountDirectory(accountName);
+ } catch (error) {
+ logger.error('create directory error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, configDir: '', error: String(error) };
+ }
+ });
+
+ ipcMain.handle('accounts:validate-symlinks', async (_event, configDir: string) => {
+ try {
+ return await validateAccountSymlinks(configDir);
+ } catch (error) {
+ logger.error('validate symlinks error', LOG_CONTEXT, { error: String(error) });
+ return { valid: false, broken: [], missing: [] };
+ }
+ });
+
+ ipcMain.handle('accounts:repair-symlinks', async (_event, configDir: string) => {
+ try {
+ return await repairAccountSymlinks(configDir);
+ } catch (error) {
+ logger.error('repair symlinks error', LOG_CONTEXT, { error: String(error) });
+ return { repaired: [], errors: [String(error)] };
+ }
+ });
+
+ ipcMain.handle('accounts:read-email', async (_event, configDir: string) => {
+ try {
+ return await readAccountEmail(configDir);
+ } catch (error) {
+ logger.error('read email error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:get-login-command', async (_event, configDir: string) => {
+ try {
+ return buildLoginCommand(configDir);
+ } catch (error) {
+ logger.error('get login command error', LOG_CONTEXT, { error: String(error) });
+ return null;
+ }
+ });
+
+ ipcMain.handle('accounts:remove-directory', async (_event, configDir: string) => {
+ try {
+ return await removeAccountDirectory(configDir);
+ } catch (error) {
+ logger.error('remove directory error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ ipcMain.handle('accounts:validate-remote-dir', async (_event, params: {
+ sshConfig: { host: string; user?: string; port?: number };
+ configDir: string;
+ }) => {
+ try {
+ return await validateRemoteAccountDir(params.sshConfig, params.configDir);
+ } catch (error) {
+ logger.error('validate remote dir error', LOG_CONTEXT, { error: String(error) });
+ return { exists: false, hasAuth: false, symlinksValid: false, error: String(error) };
+ }
+ });
}
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index 014857561..17d22a199 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -109,6 +109,44 @@ export function createAccountsApi() {
selectNext: (excludeIds?: string[]): Promise =>
ipcRenderer.invoke('accounts:select-next', excludeIds),
+ // --- Account Setup ---
+
+ /** Validate that the base ~/.claude directory exists */
+ validateBaseDir: (): Promise<{ valid: boolean; baseDir: string; errors: string[] }> =>
+ ipcRenderer.invoke('accounts:validate-base-dir'),
+
+ /** Discover existing ~/.claude-* account directories */
+ discoverExisting: (): Promise> =>
+ ipcRenderer.invoke('accounts:discover-existing'),
+
+ /** Create a new account directory with symlinks */
+ createDirectory: (name: string): Promise<{ success: boolean; configDir: string; error?: string }> =>
+ ipcRenderer.invoke('accounts:create-directory', name),
+
+ /** Validate symlinks in an account directory */
+ validateSymlinks: (configDir: string): Promise<{ valid: boolean; broken: string[]; missing: string[] }> =>
+ ipcRenderer.invoke('accounts:validate-symlinks', configDir),
+
+ /** Repair broken or missing symlinks */
+ repairSymlinks: (configDir: string): Promise<{ repaired: string[]; errors: string[] }> =>
+ ipcRenderer.invoke('accounts:repair-symlinks', configDir),
+
+ /** Read the email from an account's .claude.json */
+ readEmail: (configDir: string): Promise =>
+ ipcRenderer.invoke('accounts:read-email', configDir),
+
+ /** Get the login command string for an account */
+ getLoginCommand: (configDir: string): Promise =>
+ ipcRenderer.invoke('accounts:get-login-command', configDir),
+
+ /** Remove an account directory */
+ removeDirectory: (configDir: string): Promise<{ success: boolean; error?: string }> =>
+ ipcRenderer.invoke('accounts:remove-directory', configDir),
+
+ /** Validate an account directory on a remote SSH host */
+ validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }): Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }> =>
+ ipcRenderer.invoke('accounts:validate-remote-dir', params),
+
// --- Event Listeners ---
/**
From 691d03b4943fdcc00041857c8964c5c918ae5aae Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 09:35:42 -0500
Subject: [PATCH 06/59] MAESTRO: feat: add account management UI to Settings
panel
Adds Accounts tab to Settings modal with:
- Account list with status badges, default indicators, and inline config
- Discover existing accounts and import them
- Create new account flow with login command generation
- Per-account token limit, window duration, and auto-switch toggle
- Symlink validation and repair buttons
- Global switch configuration (thresholds, strategy, prompt toggle)
- CLAUDE_CONFIG_DIR conflict detection banner for manual overrides
- MaestroAPI type declaration for window.maestro.accounts
Co-Authored-By: Claude Opus 4.6
---
src/renderer/components/AccountsPanel.tsx | 957 ++++++++++++++++++++++
src/renderer/components/SettingsModal.tsx | 24 +-
src/renderer/global.d.ts | 32 +
3 files changed, 1009 insertions(+), 4 deletions(-)
create mode 100644 src/renderer/components/AccountsPanel.tsx
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
new file mode 100644
index 000000000..8508a43ea
--- /dev/null
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -0,0 +1,957 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Plus,
+ Trash2,
+ Star,
+ Search,
+ RefreshCw,
+ ChevronDown,
+ ChevronRight,
+ AlertTriangle,
+ Check,
+ Wrench,
+ Download,
+} from 'lucide-react';
+import type { Theme } from '../types';
+import type { AccountProfile, AccountSwitchConfig } from '../../shared/account-types';
+import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
+
+interface AccountsPanelProps {
+ theme: Theme;
+}
+
+interface DiscoveredAccount {
+ configDir: string;
+ name: string;
+ email: string | null;
+ hasAuth: boolean;
+}
+
+interface ConflictingSession {
+ sessionId: string;
+ sessionName: string;
+ manualConfigDir: string;
+}
+
+const WINDOW_DURATION_OPTIONS = [
+ { label: '1 hour', value: 1 * 60 * 60 * 1000 },
+ { label: '2 hours', value: 2 * 60 * 60 * 1000 },
+ { label: '5 hours', value: 5 * 60 * 60 * 1000 },
+ { label: '8 hours', value: 8 * 60 * 60 * 1000 },
+ { label: '24 hours', value: 24 * 60 * 60 * 1000 },
+];
+
+export function AccountsPanel({ theme }: AccountsPanelProps) {
+ const [accounts, setAccounts] = useState([]);
+ const [switchConfig, setSwitchConfig] = useState(ACCOUNT_SWITCH_DEFAULTS);
+ const [discoveredAccounts, setDiscoveredAccounts] = useState(null);
+ const [isDiscovering, setIsDiscovering] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [newAccountName, setNewAccountName] = useState('');
+ const [createStep, setCreateStep] = useState<'idle' | 'created' | 'login-ready'>('idle');
+ const [createdConfigDir, setCreatedConfigDir] = useState('');
+ const [loginCommand, setLoginCommand] = useState('');
+ const [editingAccountId, setEditingAccountId] = useState(null);
+ const [conflictingSessions, setConflictingSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ const refreshAccounts = useCallback(async () => {
+ try {
+ const list = (await window.maestro.accounts.list()) as AccountProfile[];
+ setAccounts(list);
+ } catch (err) {
+ console.error('Failed to load accounts:', err);
+ }
+ }, []);
+
+ const refreshSwitchConfig = useCallback(async () => {
+ try {
+ const config = (await window.maestro.accounts.getSwitchConfig()) as AccountSwitchConfig;
+ setSwitchConfig(config);
+ } catch (err) {
+ console.error('Failed to load switch config:', err);
+ }
+ }, []);
+
+ // Load accounts, switch config, and check for conflicts on mount
+ useEffect(() => {
+ const init = async () => {
+ setLoading(true);
+ await Promise.all([refreshAccounts(), refreshSwitchConfig()]);
+
+ // Check for sessions with manual CLAUDE_CONFIG_DIR
+ try {
+ const sessions = await window.maestro.sessions.getAll();
+ const conflicts = sessions
+ .filter(
+ (s: any) => s.customEnvVars?.CLAUDE_CONFIG_DIR && !s.accountId
+ )
+ .map((s: any) => ({
+ sessionId: s.id,
+ sessionName: s.name || s.id,
+ manualConfigDir: s.customEnvVars!.CLAUDE_CONFIG_DIR,
+ }));
+ setConflictingSessions(conflicts);
+ } catch (err) {
+ console.error('Failed to check session conflicts:', err);
+ }
+
+ setLoading(false);
+ };
+ init();
+ }, [refreshAccounts, refreshSwitchConfig]);
+
+ const handleDiscover = async () => {
+ setIsDiscovering(true);
+ try {
+ const found = await window.maestro.accounts.discoverExisting();
+ // Filter out already-registered accounts
+ const existingDirs = new Set(accounts.map((a) => a.configDir));
+ setDiscoveredAccounts(found.filter((d) => !existingDirs.has(d.configDir)));
+ } catch (err) {
+ console.error('Failed to discover accounts:', err);
+ } finally {
+ setIsDiscovering(false);
+ }
+ };
+
+ const handleImportDiscovered = async (discovered: DiscoveredAccount) => {
+ try {
+ await window.maestro.accounts.add({
+ name: discovered.name,
+ email: discovered.email || discovered.name,
+ configDir: discovered.configDir,
+ });
+ await refreshAccounts();
+ // Remove from discovered list
+ setDiscoveredAccounts((prev) =>
+ prev ? prev.filter((d) => d.configDir !== discovered.configDir) : null
+ );
+ } catch (err) {
+ console.error('Failed to import account:', err);
+ }
+ };
+
+ const handleCreateAndLogin = async () => {
+ if (!newAccountName.trim()) return;
+ setIsCreating(true);
+ try {
+ const result = await window.maestro.accounts.createDirectory(newAccountName.trim());
+ if (!result.success) {
+ console.error('Failed to create directory:', result.error);
+ return;
+ }
+ setCreatedConfigDir(result.configDir);
+
+ const cmd = await window.maestro.accounts.getLoginCommand(result.configDir);
+ if (cmd) {
+ setLoginCommand(cmd);
+ setCreateStep('login-ready');
+ } else {
+ setCreateStep('created');
+ }
+ } catch (err) {
+ console.error('Failed to create account:', err);
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleLoginComplete = async () => {
+ try {
+ const email = await window.maestro.accounts.readEmail(createdConfigDir);
+ await window.maestro.accounts.add({
+ name: email || newAccountName.trim(),
+ email: email || newAccountName.trim(),
+ configDir: createdConfigDir,
+ });
+ await refreshAccounts();
+ // Reset create flow
+ setNewAccountName('');
+ setCreateStep('idle');
+ setCreatedConfigDir('');
+ setLoginCommand('');
+ } catch (err) {
+ console.error('Failed to register account after login:', err);
+ }
+ };
+
+ const handleRemoveAccount = async (id: string) => {
+ try {
+ await window.maestro.accounts.remove(id);
+ await refreshAccounts();
+ } catch (err) {
+ console.error('Failed to remove account:', err);
+ }
+ };
+
+ const handleSetDefault = async (id: string) => {
+ try {
+ await window.maestro.accounts.setDefault(id);
+ await refreshAccounts();
+ } catch (err) {
+ console.error('Failed to set default:', err);
+ }
+ };
+
+ const handleUpdateAccount = async (id: string, updates: Partial) => {
+ try {
+ await window.maestro.accounts.update(id, updates as Record);
+ await refreshAccounts();
+ } catch (err) {
+ console.error('Failed to update account:', err);
+ }
+ };
+
+ const handleUpdateSwitchConfig = async (updates: Partial) => {
+ try {
+ await window.maestro.accounts.updateSwitchConfig(updates as Record);
+ await refreshSwitchConfig();
+ } catch (err) {
+ console.error('Failed to update switch config:', err);
+ }
+ };
+
+ const handleValidateSymlinks = async (configDir: string) => {
+ try {
+ const result = await window.maestro.accounts.validateSymlinks(configDir);
+ if (result.valid) {
+ alert('All symlinks are valid.');
+ } else {
+ alert(
+ `Symlink issues found:\nBroken: ${result.broken.join(', ') || 'none'}\nMissing: ${result.missing.join(', ') || 'none'}`
+ );
+ }
+ } catch (err) {
+ console.error('Failed to validate symlinks:', err);
+ }
+ };
+
+ const handleRepairSymlinks = async (configDir: string) => {
+ try {
+ const result = await window.maestro.accounts.repairSymlinks(configDir);
+ if (result.errors.length === 0) {
+ alert(`Repaired: ${result.repaired.join(', ') || 'none needed'}`);
+ } else {
+ alert(`Repair errors: ${result.errors.join(', ')}`);
+ }
+ await refreshAccounts();
+ } catch (err) {
+ console.error('Failed to repair symlinks:', err);
+ }
+ };
+
+ const statusBadge = (status: AccountProfile['status']) => {
+ const styles: Record<
+ string,
+ { bg: string; fg: string }
+ > = {
+ active: { bg: theme.colors.success + '20', fg: theme.colors.success },
+ throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning },
+ expired: { bg: theme.colors.error + '20', fg: theme.colors.error },
+ disabled: { bg: theme.colors.error + '20', fg: theme.colors.error },
+ };
+ const s = styles[status] || styles.disabled;
+ return (
+
+ {status}
+
+ );
+ };
+
+ if (loading) {
+ return (
+
+ Loading accounts...
+
+ );
+ }
+
+ return (
+
+ {/* Conflict Warning Banner */}
+ {conflictingSessions.length > 0 && (
+
+
+
+ Manual CLAUDE_CONFIG_DIR Detected
+
+
+ {conflictingSessions.length} session(s) have CLAUDE_CONFIG_DIR set manually in
+ custom env vars. These sessions will not be managed by the account system.
+ Consider migrating them to managed accounts.
+
+ {conflictingSessions.map((s) => (
+
+ • {s.sessionName}: {s.manualConfigDir}
+
+ ))}
+
+ )}
+
+ {/* Registered Accounts */}
+
+
+
+ Registered Accounts
+
+
+
+
+
+
+ {accounts.length === 0 ? (
+
+ No accounts registered. Use "Discover Existing" or "Create
+ New" below.
+
+ ) : (
+
+ {accounts.map((account) => (
+
+
+
+
+
+
+ {account.email || account.name}
+
+ {account.isDefault && (
+
+ )}
+ {statusBadge(account.status)}
+
+
+ {account.configDir}
+ {account.tokenLimitPerWindow > 0 && (
+
+ {' '}
+ · Limit:{' '}
+ {account.tokenLimitPerWindow.toLocaleString()}{' '}
+ tokens
+
+ )}
+
+
+
+
+
+ setEditingAccountId(
+ editingAccountId === account.id
+ ? null
+ : account.id
+ )
+ }
+ className="p-1.5 rounded hover:bg-white/10 transition-colors"
+ title="Configure"
+ style={{ color: theme.colors.textDim }}
+ >
+ {editingAccountId === account.id ? (
+
+ ) : (
+
+ )}
+
+ {!account.isDefault && (
+ handleSetDefault(account.id)}
+ className="p-1.5 rounded hover:bg-white/10 transition-colors"
+ title="Set as default"
+ style={{ color: theme.colors.textDim }}
+ >
+
+
+ )}
+ handleRemoveAccount(account.id)}
+ className="p-1.5 rounded hover:bg-white/10 transition-colors"
+ title="Remove account"
+ style={{ color: theme.colors.textDim }}
+ >
+
+
+
+
+
+ {/* Expanded per-account configuration */}
+ {editingAccountId === account.id && (
+
+
+
+
+ Token limit per window (0 = no limit)
+
+
+ handleUpdateAccount(account.id, {
+ tokenLimitPerWindow:
+ parseInt(e.target.value) || 0,
+ })
+ }
+ className="w-full p-2 rounded border bg-transparent outline-none text-xs font-mono"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ }}
+ min={0}
+ />
+
+
+
+ Window duration
+
+
+ handleUpdateAccount(account.id, {
+ tokenWindowMs: parseInt(e.target.value),
+ })
+ }
+ className="w-full p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ {WINDOW_DURATION_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ Auto-switch enabled
+
+
+ handleUpdateAccount(account.id, {
+ autoSwitchEnabled: !account.autoSwitchEnabled,
+ })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative"
+ style={{
+ backgroundColor: account.autoSwitchEnabled
+ ? theme.colors.accent
+ : theme.colors.border,
+ }}
+ >
+
+
+
+
+
+
+ handleValidateSymlinks(account.configDir)
+ }
+ className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
+ style={{
+ color: theme.colors.textDim,
+ border: `1px solid ${theme.colors.border}`,
+ }}
+ >
+
+ Validate Symlinks
+
+
+ handleRepairSymlinks(account.configDir)
+ }
+ className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
+ style={{
+ color: theme.colors.textDim,
+ border: `1px solid ${theme.colors.border}`,
+ }}
+ >
+
+ Repair Symlinks
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Add Account Section */}
+
+
+ Add Account
+
+
+
+
+
+ {isDiscovering ? 'Searching...' : 'Discover Existing'}
+
+
+
+ {/* Discovered accounts */}
+ {discoveredAccounts !== null && (
+
+ {discoveredAccounts.length === 0 ? (
+
+ No unregistered account directories found.
+
+ ) : (
+
+ {discoveredAccounts.map((d) => (
+
+
+
+ {d.email || d.name}
+
+
+ {d.configDir}
+ {d.hasAuth && (
+
+ {' '}
+ · Authenticated
+
+ )}
+
+
+
handleImportDiscovered(d)}
+ className="flex items-center gap-1 px-2 py-1 rounded text-xs font-bold transition-colors"
+ style={{
+ backgroundColor: theme.colors.accent,
+ color: theme.colors.accentForeground,
+ }}
+ >
+
+ Import
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Create new account */}
+
+
+ Create New Account
+
+
+ {createStep === 'idle' && (
+
+
setNewAccountName(e.target.value)}
+ placeholder="Account name (e.g., work, personal)"
+ className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateAndLogin()}
+ />
+
+
+ {isCreating ? 'Creating...' : 'Create & Login'}
+
+
+ )}
+
+ {createStep === 'login-ready' && (
+
+
+ Directory created at{' '}
+ {createdConfigDir} . Run the
+ following command in a terminal to log in:
+
+
+ {loginCommand}
+
+
+
+
+ Login Complete
+
+ {
+ setCreateStep('idle');
+ setCreatedConfigDir('');
+ setLoginCommand('');
+ }}
+ className="px-3 py-2 rounded text-xs transition-colors hover:bg-white/10"
+ style={{ color: theme.colors.textDim }}
+ >
+ Cancel
+
+
+
+ )}
+
+ {createStep === 'created' && (
+
+
+ Directory created at{' '}
+ {createdConfigDir} . Could
+ not determine login command. Please authenticate manually and
+ click "Login Complete".
+
+
+
+
+ Login Complete
+
+ {
+ setCreateStep('idle');
+ setCreatedConfigDir('');
+ setLoginCommand('');
+ }}
+ className="px-3 py-2 rounded text-xs transition-colors hover:bg-white/10"
+ style={{ color: theme.colors.textDim }}
+ >
+ Cancel
+
+
+
+ )}
+
+
+
+ {/* Global Switch Configuration */}
+
+
+ Auto-Switch Configuration
+
+
+
+ {/* Enable/disable auto-switching */}
+
+
+ Enable auto-switching
+
+
+ handleUpdateSwitchConfig({ enabled: !switchConfig.enabled })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative"
+ style={{
+ backgroundColor: switchConfig.enabled
+ ? theme.colors.accent
+ : theme.colors.border,
+ }}
+ >
+
+
+
+
+ {/* Prompt before switch */}
+
+
+ Prompt before switching
+
+
+ handleUpdateSwitchConfig({
+ promptBeforeSwitch: !switchConfig.promptBeforeSwitch,
+ })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative"
+ style={{
+ backgroundColor: switchConfig.promptBeforeSwitch
+ ? theme.colors.accent
+ : theme.colors.border,
+ }}
+ >
+
+
+
+
+ {/* Warning threshold */}
+
+
+
+ Warning threshold
+
+
+ {switchConfig.warningThresholdPercent}%
+
+
+
+ handleUpdateSwitchConfig({
+ warningThresholdPercent: parseInt(e.target.value),
+ })
+ }
+ className="w-full"
+ style={{ accentColor: theme.colors.accent }}
+ />
+
+
+ {/* Auto-switch threshold */}
+
+
+
+ Auto-switch threshold
+
+
+ {switchConfig.autoSwitchThresholdPercent}%
+
+
+
+ handleUpdateSwitchConfig({
+ autoSwitchThresholdPercent: parseInt(e.target.value),
+ })
+ }
+ className="w-full"
+ style={{ accentColor: theme.colors.accent }}
+ />
+
+
+ {/* Selection strategy */}
+
+
+ Selection strategy
+
+
+ handleUpdateSwitchConfig({
+ selectionStrategy: e.target.value as
+ | 'least-used'
+ | 'round-robin',
+ })
+ }
+ className="w-full p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ Least Used
+ Round Robin
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index fd28c1690..ab2b64f1e 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -32,6 +32,7 @@ import {
PartyPopper,
Tag,
User,
+ Users,
Clapperboard,
} from 'lucide-react';
import { useSettings } from '../hooks';
@@ -60,6 +61,7 @@ import { NotificationsPanel } from './NotificationsPanel';
import { SshRemotesSection } from './Settings/SshRemotesSection';
import { SshRemoteIgnoreSection } from './Settings/SshRemoteIgnoreSection';
import { AgentConfigPanel } from './shared/AgentConfigPanel';
+import { AccountsPanel } from './AccountsPanel';
import { AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
// Feature flags - set to true to enable dormant features
@@ -286,7 +288,8 @@ interface SettingsModalProps {
| 'theme'
| 'notifications'
| 'aicommands'
- | 'ssh';
+ | 'ssh'
+ | 'accounts';
hasNoAgents?: boolean;
onThemeImportError?: (message: string) => void;
onThemeImportSuccess?: (message: string) => void;
@@ -334,7 +337,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
} = useSettings();
const [activeTab, setActiveTab] = useState<
- 'general' | 'display' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh' | 'director-notes'
+ 'general' | 'display' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh' | 'accounts' | 'director-notes'
>('general');
const [systemFonts, setSystemFonts] = useState([]);
const [customFonts, setCustomFonts] = useState([]);
@@ -554,10 +557,11 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
| 'notifications'
| 'aicommands'
| 'ssh'
+ | 'accounts'
| 'director-notes'
> = FEATURE_FLAGS.LLM_SETTINGS
- ? ['general', 'display', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'director-notes']
- : ['general', 'display', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'director-notes'];
+ ? ['general', 'display', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'accounts', 'director-notes']
+ : ['general', 'display', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'accounts', 'director-notes'];
const currentIndex = tabs.indexOf(activeTab);
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') {
@@ -1027,6 +1031,16 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
{activeTab === 'ssh' && SSH Hosts }
+ setActiveTab('accounts')}
+ className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'accounts' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`}
+ style={{ color: activeTab === 'accounts' ? theme.colors.textMain : theme.colors.textDim }}
+ tabIndex={-1}
+ title="Accounts"
+ >
+
+ {activeTab === 'accounts' && Accounts }
+
setActiveTab('director-notes')}
className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'director-notes' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`}
@@ -2744,6 +2758,8 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
)}
+ {activeTab === 'accounts' && }
+
{activeTab === 'director-notes' && (() => {
// Compute derived values for director-notes tab
const dnAvailableTiles = AGENT_TILES.filter((tile) => {
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 4f61d6f57..2b591d601 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2607,6 +2607,38 @@ interface MaestroAPI {
}) => Promise;
};
+ // Account Multiplexing API
+ accounts: {
+ list: () => Promise;
+ get: (id: string) => Promise;
+ add: (params: { name: string; email: string; configDir: string }) => Promise;
+ update: (id: string, updates: Record) => Promise;
+ remove: (id: string) => Promise;
+ setDefault: (id: string) => Promise;
+ assign: (sessionId: string, accountId: string) => Promise;
+ getAssignment: (sessionId: string) => Promise;
+ getAllAssignments: () => Promise;
+ getUsage: (accountId: string, windowStart: number, windowEnd: number) => Promise;
+ getAllUsage: () => Promise;
+ getThrottleEvents: (accountId?: string, since?: number) => Promise;
+ getSwitchConfig: () => Promise;
+ updateSwitchConfig: (updates: Record) => Promise;
+ getDefault: () => Promise;
+ selectNext: (excludeIds?: string[]) => Promise;
+ validateBaseDir: () => Promise<{ valid: boolean; baseDir: string; errors: string[] }>;
+ discoverExisting: () => Promise>;
+ createDirectory: (name: string) => Promise<{ success: boolean; configDir: string; error?: string }>;
+ validateSymlinks: (configDir: string) => Promise<{ valid: boolean; broken: string[]; missing: string[] }>;
+ repairSymlinks: (configDir: string) => Promise<{ repaired: string[]; errors: string[] }>;
+ readEmail: (configDir: string) => Promise;
+ getLoginCommand: (configDir: string) => Promise;
+ removeDirectory: (configDir: string) => Promise<{ success: boolean; error?: string }>;
+ validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }) => Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }>;
+ onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number; totalTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void;
+ onLimitWarning: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
+ onLimitReached: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
+ };
+
// Director's Notes API (unified history + synopsis generation)
directorNotes: {
getUnifiedHistory: (options: {
From e2097c21ee9261a2dbe959aa066a35863387afac Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 09:52:17 -0500
Subject: [PATCH 07/59] MAESTRO: feat: add Account Usage Dashboard UI with lint
and test fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix TypeScript errors in AccountUsageDashboard (TS2783 duplicate property
overwrites), add 'accounts' to DashboardSkeleton viewMode type, remove
unused lucide imports, and update UsageDashboardModal tests for the new
Accounts tab (tab count 4→5, wrap-around nav, accounts IPC mocks).
Co-Authored-By: Claude Opus 4.6
---
.../components/UsageDashboardModal.test.tsx | 27 +-
.../UsageDashboard/AccountUsageDashboard.tsx | 630 ++++++++++++++++++
.../UsageDashboard/ChartSkeletons.tsx | 2 +-
3 files changed, 651 insertions(+), 8 deletions(-)
create mode 100644 src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
diff --git a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
index 0786a566e..067716147 100644
--- a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
@@ -53,6 +53,10 @@ vi.mock('lucide-react', () => {
// WeekdayComparisonChart icons
Briefcase: createIcon('briefcase', '💼'),
Coffee: createIcon('coffee', '☕'),
+ // AccountUsageDashboard icons
+ Activity: createIcon('activity', '📈'),
+ ArrowRightLeft: createIcon('arrow-right-left', '↔️'),
+ TrendingUp: createIcon('trending-up', '📈'),
};
});
@@ -94,6 +98,13 @@ const mockMaestro = {
fs: {
writeFile: mockWriteFile,
},
+ accounts: {
+ list: vi.fn(() => Promise.resolve([])),
+ getAllUsage: vi.fn(() => Promise.resolve({})),
+ getAllAssignments: vi.fn(() => Promise.resolve([])),
+ getThrottleEvents: vi.fn(() => Promise.resolve([])),
+ onUsageUpdate: vi.fn(() => vi.fn()),
+ },
};
// Set up window.maestro mock
@@ -216,11 +227,12 @@ describe('UsageDashboardModal', () => {
await waitFor(() => {
// Use getAllByRole('tab') to find tabs - there may be multiple elements with text 'Agents'
const tabs = screen.getAllByRole('tab');
- expect(tabs).toHaveLength(4);
+ expect(tabs).toHaveLength(5);
expect(tabs[0]).toHaveTextContent('Overview');
expect(tabs[1]).toHaveTextContent('Agents');
expect(tabs[2]).toHaveTextContent('Activity');
expect(tabs[3]).toHaveTextContent('Auto Run');
+ expect(tabs[4]).toHaveTextContent('Accounts');
});
});
@@ -1541,7 +1553,7 @@ describe('UsageDashboardModal', () => {
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
- expect(tabs).toHaveLength(4);
+ expect(tabs).toHaveLength(5);
// First tab (Overview) should be selected
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
@@ -1552,6 +1564,7 @@ describe('UsageDashboardModal', () => {
expect(tabs[1]).toHaveAttribute('aria-selected', 'false');
expect(tabs[2]).toHaveAttribute('aria-selected', 'false');
expect(tabs[3]).toHaveAttribute('aria-selected', 'false');
+ expect(tabs[4]).toHaveAttribute('aria-selected', 'false');
});
});
@@ -1610,12 +1623,12 @@ describe('UsageDashboardModal', () => {
const tablist = screen.getByTestId('view-mode-tabs');
- // Press ArrowLeft while on first tab - should wrap to last tab (Auto Run)
+ // Press ArrowLeft while on first tab - should wrap to last tab (Accounts)
fireEvent.keyDown(tablist, { key: 'ArrowLeft' });
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
- expect(tabs[3]).toHaveAttribute('aria-selected', 'true'); // Auto Run tab
+ expect(tabs[4]).toHaveAttribute('aria-selected', 'true'); // Accounts tab
expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
});
});
@@ -1629,11 +1642,11 @@ describe('UsageDashboardModal', () => {
const tablist = screen.getByTestId('view-mode-tabs');
- // Navigate to last tab (Auto Run)
+ // Navigate to last tab (Accounts)
fireEvent.keyDown(tablist, { key: 'ArrowLeft' }); // Wraps to last
await waitFor(() => {
- expect(screen.getAllByRole('tab')[3]).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getAllByRole('tab')[4]).toHaveAttribute('aria-selected', 'true');
});
// Press ArrowRight - should wrap to first tab (Overview)
@@ -1642,7 +1655,7 @@ describe('UsageDashboardModal', () => {
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
expect(tabs[0]).toHaveAttribute('aria-selected', 'true');
- expect(tabs[3]).toHaveAttribute('aria-selected', 'false');
+ expect(tabs[4]).toHaveAttribute('aria-selected', 'false');
});
});
diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
new file mode 100644
index 000000000..7b02bfadf
--- /dev/null
+++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
@@ -0,0 +1,630 @@
+/**
+ * AccountUsageDashboard
+ *
+ * Real-time account usage monitoring panel that shows per-account token consumption,
+ * limit progress bars, active session assignments, and throttle history.
+ * Integrated as a tab within the existing Usage Dashboard.
+ */
+
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import {
+ Users,
+ Activity,
+ AlertTriangle,
+ Zap,
+ TrendingUp,
+ ArrowRightLeft,
+} from 'lucide-react';
+import type { Theme, Session } from '../../types';
+import type {
+ AccountProfile,
+ AccountUsageSnapshot,
+ AccountAssignment,
+ AccountCapacityMetrics,
+} from '../../../shared/account-types';
+
+interface AccountUsageDashboardProps {
+ theme: Theme;
+ sessions: Session[];
+ onClose: () => void;
+}
+
+/** Format token counts with K/M suffixes */
+function formatTokens(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
+ return n.toString();
+}
+
+/** Format cost in USD */
+function formatCost(usd: number): string {
+ if (usd === 0) return '$0.00';
+ if (usd < 0.01) return '<$0.01';
+ return `$${usd.toFixed(2)}`;
+}
+
+/** Format remaining time from ms */
+function formatTimeRemaining(ms: number): string {
+ if (ms <= 0) return 'Expired';
+ const hours = Math.floor(ms / 3_600_000);
+ const minutes = Math.floor((ms % 3_600_000) / 60_000);
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+}
+
+/** Get usage color based on percentage */
+function getUsageColor(percent: number | null, theme: Theme): string {
+ if (percent === null) return theme.colors.accent;
+ if (percent > 95) return theme.colors.error;
+ if (percent > 80) return '#f97316'; // orange
+ if (percent > 60) return theme.colors.warning;
+ return theme.colors.success;
+}
+
+/** Get status badge style */
+function getStatusStyle(status: string, theme: Theme): { bg: string; fg: string } {
+ const styles: Record = {
+ active: { bg: theme.colors.success + '20', fg: theme.colors.success },
+ throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning },
+ expired: { bg: theme.colors.error + '20', fg: theme.colors.error },
+ disabled: { bg: theme.colors.error + '20', fg: theme.colors.error },
+ };
+ return styles[status] || styles.disabled;
+}
+
+interface ThrottleEvent {
+ timestamp: number;
+ accountId: string;
+ accountName?: string;
+ reason: string;
+ totalTokens: number;
+ recoveryAction?: string;
+}
+
+export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboardProps) {
+ const [accounts, setAccounts] = useState([]);
+ const [usageData, setUsageData] = useState>({});
+ const [assignments, setAssignments] = useState([]);
+ const [throttleEvents, setThrottleEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Fetch all data on mount
+ const fetchData = useCallback(async () => {
+ try {
+ const [accountList, allUsage, allAssignments, events] = await Promise.all([
+ window.maestro.accounts.list() as Promise,
+ window.maestro.accounts.getAllUsage() as Promise>,
+ window.maestro.accounts.getAllAssignments() as Promise,
+ window.maestro.accounts.getThrottleEvents() as Promise,
+ ]);
+ setAccounts(accountList);
+ setUsageData(allUsage || {});
+ setAssignments(allAssignments || []);
+ setThrottleEvents(events || []);
+ setError(null);
+ } catch (err) {
+ console.error('Failed to fetch account usage data:', err);
+ setError(err instanceof Error ? err.message : 'Failed to load account data');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchData();
+
+ // Poll every 30 seconds
+ const pollInterval = setInterval(fetchData, 30_000);
+
+ // Listen for real-time usage updates
+ const unsubUsage = window.maestro.accounts.onUsageUpdate((data) => {
+ setUsageData((prev) => {
+ const defaults: AccountUsageSnapshot = {
+ accountId: data.accountId,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheCreationTokens: 0,
+ costUsd: 0,
+ windowStart: 0,
+ windowEnd: 0,
+ queryCount: 0,
+ usagePercent: null,
+ };
+ return {
+ ...prev,
+ [data.accountId]: {
+ ...defaults,
+ ...prev[data.accountId],
+ costUsd: data.costUsd,
+ windowStart: data.windowStart,
+ windowEnd: data.windowEnd,
+ queryCount: data.queryCount,
+ usagePercent: data.usagePercent,
+ },
+ };
+ });
+ });
+
+ return () => {
+ clearInterval(pollInterval);
+ unsubUsage();
+ };
+ }, [fetchData]);
+
+ // Build session lookup map
+ const sessionMap = useMemo(() => {
+ const map = new Map();
+ for (const s of sessions) {
+ map.set(s.id, s);
+ }
+ return map;
+ }, [sessions]);
+
+ // Build account lookup map
+ const accountMap = useMemo(() => {
+ const map = new Map();
+ for (const a of accounts) {
+ map.set(a.id, a);
+ }
+ return map;
+ }, [accounts]);
+
+ // Capacity metrics (derived)
+ const capacityMetrics = useMemo((): AccountCapacityMetrics | null => {
+ if (accounts.length === 0) return null;
+
+ const totalTokens = Object.values(usageData).reduce((sum, u) => {
+ return sum + (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0);
+ }, 0);
+
+ // Estimate tokens/hour based on current window
+ const windowMs = accounts[0]?.tokenWindowMs || 5 * 60 * 60 * 1000;
+ const hoursInWindow = windowMs / 3_600_000;
+ const avgTokensPerHour = hoursInWindow > 0 ? Math.round(totalTokens / hoursInWindow / accounts.length) : 0;
+ const peakTokensPerHour = avgTokensPerHour * 1.5; // estimate
+
+ // Recommend accounts based on usage
+ const maxTokensPerAccountPerHour = accounts[0]?.tokenLimitPerWindow
+ ? accounts[0].tokenLimitPerWindow / hoursInWindow
+ : 200_000;
+ const recommended = maxTokensPerAccountPerHour > 0
+ ? Math.max(1, Math.ceil(peakTokensPerHour / maxTokensPerAccountPerHour))
+ : 1;
+
+ return {
+ avgTokensPerHour,
+ peakTokensPerHour: Math.round(peakTokensPerHour),
+ throttleEvents: throttleEvents.length,
+ recommendedAccountCount: recommended,
+ analysisWindowMs: windowMs,
+ };
+ }, [accounts, usageData, throttleEvents]);
+
+ if (loading) {
+ return (
+
+ Loading account usage data...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Failed to load account data: {error}
+
+ Retry
+
+
+ );
+ }
+
+ if (accounts.length === 0) {
+ return (
+
+
+
No accounts registered
+
+ Add accounts in Settings > Accounts to start tracking usage
+
+
+ );
+ }
+
+ return (
+
+ {/* Section 1: Account Overview Cards */}
+
+
+
+ Account Overview
+
+
+ {accounts.map((account) => {
+ const usage = usageData[account.id];
+ const percent = usage?.usagePercent ?? null;
+ const totalTokens = usage
+ ? (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0)
+ : 0;
+ const timeRemaining = usage?.windowEnd ? usage.windowEnd - Date.now() : 0;
+ const activeSessionCount = assignments.filter((a) => a.accountId === account.id).length;
+ const usageColor = getUsageColor(percent, theme);
+ const statusStyle = getStatusStyle(account.status, theme);
+
+ return (
+
+ {/* Header: name + status */}
+
+
+
+ {account.email || account.name}
+
+ {account.isDefault && (
+
+ DEFAULT
+
+ )}
+
+
+ {account.status}
+
+
+
+ {/* Progress bar */}
+
+
+
+ {percent !== null ? `${Math.round(percent)}% of limit` : 'No limit set'}
+
+
+ {formatTokens(totalTokens)}
+ {account.tokenLimitPerWindow > 0 && (
+
+ {' / '}{formatTokens(account.tokenLimitPerWindow)}
+
+ )}
+
+
+
+
+
+ {/* Stats grid */}
+
+
+
Window
+
+ {timeRemaining > 0 ? formatTimeRemaining(timeRemaining) : '—'}
+
+
+
+
Sessions
+
+ {activeSessionCount}
+
+
+
+
Cost
+
+ {formatCost(usage?.costUsd ?? 0)}
+
+
+
+
+ );
+ })}
+
+
+
+ {/* Section 2: Active Assignments Table */}
+
+
+
+ Active Assignments
+
+ {assignments.length === 0 ? (
+
+ No active account assignments
+
+ ) : (
+
+
+
+
+ Session
+ Account
+ Agent
+ Assigned
+ Status
+
+
+
+ {assignments.map((assignment) => {
+ const session = sessionMap.get(assignment.sessionId);
+ const account = accountMap.get(assignment.accountId);
+ return (
+
+
+ {session?.name || assignment.sessionId.slice(0, 8)}
+
+
+ {account?.email || assignment.accountId.slice(0, 8)}
+
+
+ {session?.toolType || '—'}
+
+
+ {new Date(assignment.assignedAt).toLocaleTimeString()}
+
+
+
+ {session?.state || 'unknown'}
+
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ {/* Section 3: Usage Timeline (simplified — bar chart per account) */}
+
+
+
+ Usage by Account
+
+
+ {accounts.map((account) => {
+ const usage = usageData[account.id];
+ const totalTokens = usage
+ ? (usage.inputTokens || 0) + (usage.outputTokens || 0) + (usage.cacheReadTokens || 0)
+ : 0;
+ const maxTokens = Math.max(
+ ...accounts.map((a) => {
+ const u = usageData[a.id];
+ return u ? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) : 0;
+ }),
+ 1
+ );
+ const barPercent = (totalTokens / maxTokens) * 100;
+ const usageColor = getUsageColor(usage?.usagePercent ?? null, theme);
+
+ return (
+
+
+ {(account.email || account.name).split('@')[0]}
+
+
+
+
+
+ {formatTokens(totalTokens)}
+
+
+
+ {/* Limit line */}
+ {account.tokenLimitPerWindow > 0 && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* Section 4: Throttle History */}
+
+
+
+ Throttle History
+
+ {throttleEvents.length === 0 ? (
+
+ No throttle events recorded
+
+ ) : (
+
+
+
+
+ Time
+ Account
+ Reason
+ Tokens
+ Recovery
+
+
+
+ {throttleEvents.slice().reverse().map((event, i) => {
+ const account = accountMap.get(event.accountId);
+ return (
+
+
+ {new Date(event.timestamp).toLocaleString()}
+
+
+ {event.accountName || account?.email || event.accountId.slice(0, 8)}
+
+
+
+ {event.reason.replace(/_/g, ' ')}
+
+
+
+ {formatTokens(event.totalTokens)}
+
+
+ {event.recoveryAction || '—'}
+
+
+ );
+ })}
+
+
+
+ )}
+
+
+ {/* Section 5: Capacity Recommendation */}
+ {capacityMetrics && (
+
+
+
+ Capacity Recommendation
+
+
+
+ Based on your usage in the current window:
+
+
+
+
Avg tokens/hour
+
+ {formatTokens(capacityMetrics.avgTokensPerHour)}
+
+
+
+
Peak tokens/hour
+
+ {formatTokens(capacityMetrics.peakTokensPerHour)}
+
+
+
+
Throttle events
+
0 ? theme.colors.warning : theme.colors.textMain }}>
+ {capacityMetrics.throttleEvents}
+
+
+
+
Recommended accounts
+
+ {capacityMetrics.recommendedAccountCount}
+ {capacityMetrics.recommendedAccountCount > accounts.length && (
+
+ (need {capacityMetrics.recommendedAccountCount - accounts.length} more)
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/UsageDashboard/ChartSkeletons.tsx b/src/renderer/components/UsageDashboard/ChartSkeletons.tsx
index dde602b9d..83283136c 100644
--- a/src/renderer/components/UsageDashboard/ChartSkeletons.tsx
+++ b/src/renderer/components/UsageDashboard/ChartSkeletons.tsx
@@ -347,7 +347,7 @@ export function DashboardSkeleton({
summaryCardsCols = 5,
autoRunStatsCols = 6,
}: SkeletonProps & {
- viewMode?: 'overview' | 'agents' | 'activity' | 'autorun';
+ viewMode?: 'overview' | 'agents' | 'activity' | 'autorun' | 'accounts';
chartGridCols?: number;
summaryCardsCols?: number;
autoRunStatsCols?: number;
From d604b42703e8d4d56694a6eb073c36c729bbe87b Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 10:04:10 -0500
Subject: [PATCH 08/59] MAESTRO: feat: add throttle detection and account
switch trigger
Add AccountThrottleHandler that bridges rate-limit error detection with
account switching. When rate_limited or auth_expired errors are detected
on sessions with account assignments, records the throttle event in stats
DB, marks the account as throttled, and notifies the renderer with switch
recommendations (prompt or auto-switch depending on config).
Also adds auto-recovery from throttle state when the usage window advances,
and preload bridge events for throttle/switch/status-changed notifications.
Co-Authored-By: Claude Opus 4.6
---
.../accounts/account-throttle-handler.test.ts | 264 ++++++++++++++++++
src/main/accounts/account-throttle-handler.ts | 152 ++++++++++
src/main/index.ts | 8 +-
src/main/preload/accounts.ts | 48 ++++
.../account-usage-listener.ts | 16 ++
src/main/process-listeners/error-listener.ts | 28 +-
src/main/process-listeners/index.ts | 7 +-
src/main/process-listeners/types.ts | 3 +
8 files changed, 522 insertions(+), 4 deletions(-)
create mode 100644 src/__tests__/main/accounts/account-throttle-handler.test.ts
create mode 100644 src/main/accounts/account-throttle-handler.ts
diff --git a/src/__tests__/main/accounts/account-throttle-handler.test.ts b/src/__tests__/main/accounts/account-throttle-handler.test.ts
new file mode 100644
index 000000000..5502c3cb1
--- /dev/null
+++ b/src/__tests__/main/accounts/account-throttle-handler.test.ts
@@ -0,0 +1,264 @@
+/**
+ * Tests for AccountThrottleHandler.
+ * Validates throttle detection, stats recording, account status updates,
+ * and switch recommendation logic.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { AccountThrottleHandler } from '../../../main/accounts/account-throttle-handler';
+import type { AccountRegistry } from '../../../main/accounts/account-registry';
+import type { StatsDB } from '../../../main/stats';
+import type { AccountProfile, AccountSwitchConfig } from '../../../shared/account-types';
+
+function createMockAccount(overrides: Partial = {}): AccountProfile {
+ return {
+ id: 'acct-1',
+ name: 'Test Account',
+ email: 'test@example.com',
+ configDir: '/home/test/.claude-test',
+ agentType: 'claude-code',
+ status: 'active',
+ authMethod: 'oauth',
+ addedAt: Date.now(),
+ lastUsedAt: Date.now(),
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ isDefault: true,
+ autoSwitchEnabled: true,
+ ...overrides,
+ };
+}
+
+function createMockSwitchConfig(overrides: Partial = {}): AccountSwitchConfig {
+ return {
+ enabled: true,
+ promptBeforeSwitch: true,
+ autoSwitchThresholdPercent: 90,
+ warningThresholdPercent: 75,
+ selectionStrategy: 'round-robin',
+ ...overrides,
+ };
+}
+
+describe('AccountThrottleHandler', () => {
+ let handler: AccountThrottleHandler;
+ let mockRegistry: {
+ get: ReturnType;
+ setStatus: ReturnType;
+ getSwitchConfig: ReturnType;
+ selectNextAccount: ReturnType;
+ getAssignment: ReturnType;
+ };
+ let mockStatsDb: {
+ isReady: ReturnType;
+ getAccountUsageInWindow: ReturnType;
+ insertThrottleEvent: ReturnType;
+ };
+ let mockSafeSend: ReturnType;
+ let mockLogger: {
+ info: ReturnType;
+ error: ReturnType;
+ warn: ReturnType;
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockRegistry = {
+ get: vi.fn(),
+ setStatus: vi.fn(),
+ getSwitchConfig: vi.fn().mockReturnValue(createMockSwitchConfig()),
+ selectNextAccount: vi.fn(),
+ getAssignment: vi.fn(),
+ };
+
+ mockStatsDb = {
+ isReady: vi.fn().mockReturnValue(true),
+ getAccountUsageInWindow: vi.fn().mockReturnValue({
+ inputTokens: 50000,
+ outputTokens: 20000,
+ cacheReadTokens: 10000,
+ cacheCreationTokens: 5000,
+ costUsd: 1.5,
+ queryCount: 10,
+ }),
+ insertThrottleEvent: vi.fn().mockReturnValue('event-1'),
+ };
+
+ mockSafeSend = vi.fn();
+ mockLogger = {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ };
+
+ handler = new AccountThrottleHandler(
+ mockRegistry as unknown as AccountRegistry,
+ () => mockStatsDb as unknown as StatsDB,
+ mockSafeSend,
+ mockLogger,
+ );
+ });
+
+ it('should record throttle event and mark account as throttled', () => {
+ const account = createMockAccount();
+ mockRegistry.get.mockReturnValue(account);
+ mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ enabled: false }));
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockStatsDb.insertThrottleEvent).toHaveBeenCalledWith(
+ 'acct-1', 'session-1', 'rate_limited',
+ 85000, // 50000 + 20000 + 10000 + 5000
+ expect.any(Number), expect.any(Number)
+ );
+
+ expect(mockRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'throttled');
+ });
+
+ it('should send throttled notification when auto-switch is disabled', () => {
+ const account = createMockAccount();
+ mockRegistry.get.mockReturnValue(account);
+ mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ enabled: false }));
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:throttled', expect.objectContaining({
+ accountId: 'acct-1',
+ accountName: 'Test Account',
+ autoSwitchAvailable: false,
+ }));
+ });
+
+ it('should send throttled notification with noAlternatives when no accounts available', () => {
+ const account = createMockAccount();
+ mockRegistry.get.mockReturnValue(account);
+ mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({ enabled: true }));
+ mockRegistry.selectNextAccount.mockReturnValue(null);
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:throttled', expect.objectContaining({
+ accountId: 'acct-1',
+ noAlternatives: true,
+ }));
+ });
+
+ it('should send switch-prompt when promptBeforeSwitch is true', () => {
+ const account = createMockAccount();
+ const nextAccount = createMockAccount({ id: 'acct-2', name: 'Second Account' });
+ mockRegistry.get.mockReturnValue(account);
+ mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({
+ enabled: true,
+ promptBeforeSwitch: true,
+ }));
+ mockRegistry.selectNextAccount.mockReturnValue(nextAccount);
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-prompt', expect.objectContaining({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ fromAccountName: 'Test Account',
+ toAccountId: 'acct-2',
+ toAccountName: 'Second Account',
+ reason: 'rate_limited',
+ }));
+ });
+
+ it('should send switch-execute when promptBeforeSwitch is false', () => {
+ const account = createMockAccount();
+ const nextAccount = createMockAccount({ id: 'acct-2', name: 'Second Account' });
+ mockRegistry.get.mockReturnValue(account);
+ mockRegistry.getSwitchConfig.mockReturnValue(createMockSwitchConfig({
+ enabled: true,
+ promptBeforeSwitch: false,
+ }));
+ mockRegistry.selectNextAccount.mockReturnValue(nextAccount);
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-execute', expect.objectContaining({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ automatic: true,
+ }));
+ });
+
+ it('should skip if account not found', () => {
+ mockRegistry.get.mockReturnValue(null);
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-unknown',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockStatsDb.insertThrottleEvent).not.toHaveBeenCalled();
+ expect(mockRegistry.setStatus).not.toHaveBeenCalled();
+ });
+
+ it('should skip if stats DB is not ready', () => {
+ const account = createMockAccount();
+ mockRegistry.get.mockReturnValue(account);
+ mockStatsDb.isReady.mockReturnValue(false);
+
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+
+ expect(mockStatsDb.insertThrottleEvent).not.toHaveBeenCalled();
+ expect(mockRegistry.setStatus).not.toHaveBeenCalled();
+ });
+
+ it('should catch and log errors without throwing', () => {
+ mockRegistry.get.mockImplementation(() => {
+ throw new Error('Test error');
+ });
+
+ expect(() => {
+ handler.handleThrottle({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ errorType: 'rate_limited',
+ errorMessage: 'Too many requests',
+ });
+ }).not.toThrow();
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ 'Failed to handle throttle', 'account-throttle',
+ expect.objectContaining({ error: 'Error: Test error' })
+ );
+ });
+});
diff --git a/src/main/accounts/account-throttle-handler.ts b/src/main/accounts/account-throttle-handler.ts
new file mode 100644
index 000000000..b04edabb1
--- /dev/null
+++ b/src/main/accounts/account-throttle-handler.ts
@@ -0,0 +1,152 @@
+/**
+ * Account Throttle Handler
+ *
+ * Handles throttle/rate-limit detection for account multiplexing.
+ * When a throttle is detected:
+ * 1. Records the throttle event in stats DB
+ * 2. Marks the account as throttled
+ * 3. Determines if auto-switch should occur
+ * 4. Notifies the renderer with switch recommendation
+ */
+
+import type { AccountRegistry } from './account-registry';
+import type { StatsDB } from '../stats';
+import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types';
+
+const LOG_CONTEXT = 'account-throttle';
+
+export interface ThrottleContext {
+ sessionId: string;
+ accountId: string;
+ errorType: string;
+ errorMessage: string;
+}
+
+/**
+ * Calculate the window boundaries for a given timestamp and window size.
+ * Windows are aligned to fixed intervals from midnight.
+ */
+function getWindowBounds(timestamp: number, windowMs: number): { start: number; end: number } {
+ const dayStart = new Date(timestamp);
+ dayStart.setHours(0, 0, 0, 0);
+ const dayStartMs = dayStart.getTime();
+ const windowsSinceDayStart = Math.floor((timestamp - dayStartMs) / windowMs);
+ const start = dayStartMs + windowsSinceDayStart * windowMs;
+ const end = start + windowMs;
+ return { start, end };
+}
+
+export class AccountThrottleHandler {
+ constructor(
+ private accountRegistry: AccountRegistry,
+ private getStatsDB: () => StatsDB,
+ private safeSend: (channel: string, ...args: unknown[]) => void,
+ private logger: {
+ info: (message: string, context: string, data?: Record) => void;
+ error: (message: string, context: string, data?: Record) => void;
+ warn: (message: string, context: string, data?: Record) => void;
+ },
+ ) {}
+
+ /**
+ * Called when a rate_limited or similar error is detected on a session
+ * that has an account assignment.
+ */
+ handleThrottle(context: ThrottleContext): void {
+ const { sessionId, accountId, errorType, errorMessage } = context;
+
+ try {
+ // 1. Look up the account
+ const account = this.accountRegistry.get(accountId);
+ if (!account) return;
+
+ const statsDb = this.getStatsDB();
+ if (!statsDb.isReady()) return;
+
+ const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS;
+ const now = Date.now();
+ const { start, end } = getWindowBounds(now, windowMs);
+
+ // Get tokens at time of throttle
+ const usage = statsDb.getAccountUsageInWindow(accountId, start, end);
+ const tokensAtThrottle = usage.inputTokens + usage.outputTokens
+ + usage.cacheReadTokens + usage.cacheCreationTokens;
+
+ // Record throttle event
+ statsDb.insertThrottleEvent(
+ accountId, sessionId, errorType,
+ tokensAtThrottle, start, end
+ );
+
+ // 2. Mark account as throttled
+ this.accountRegistry.setStatus(accountId, 'throttled');
+ this.logger.warn(`Account ${account.name} throttled`, LOG_CONTEXT, {
+ reason: errorType, tokens: tokensAtThrottle, sessionId,
+ });
+
+ // 3. Determine if auto-switch should occur
+ const switchConfig = this.accountRegistry.getSwitchConfig();
+ if (!switchConfig.enabled) {
+ // Auto-switching disabled — just notify
+ this.safeSend('account:throttled', {
+ accountId,
+ accountName: account.name,
+ sessionId,
+ reason: errorType,
+ message: errorMessage,
+ tokensAtThrottle,
+ autoSwitchAvailable: false,
+ });
+ return;
+ }
+
+ // 4. Find next available account
+ const nextAccount = this.accountRegistry.selectNextAccount([accountId]);
+ if (!nextAccount) {
+ // No alternative accounts available
+ this.safeSend('account:throttled', {
+ accountId,
+ accountName: account.name,
+ sessionId,
+ reason: errorType,
+ message: errorMessage,
+ tokensAtThrottle,
+ autoSwitchAvailable: false,
+ noAlternatives: true,
+ });
+ this.logger.warn('No alternative accounts available for switching', LOG_CONTEXT);
+ return;
+ }
+
+ // 5. Notify renderer with switch recommendation
+ if (switchConfig.promptBeforeSwitch) {
+ // Prompt mode: ask user to confirm switch
+ this.safeSend('account:switch-prompt', {
+ sessionId,
+ fromAccountId: accountId,
+ fromAccountName: account.name,
+ toAccountId: nextAccount.id,
+ toAccountName: nextAccount.name,
+ reason: errorType,
+ tokensAtThrottle,
+ });
+ } else {
+ // Auto mode: tell renderer to execute switch immediately
+ this.safeSend('account:switch-execute', {
+ sessionId,
+ fromAccountId: accountId,
+ fromAccountName: account.name,
+ toAccountId: nextAccount.id,
+ toAccountName: nextAccount.name,
+ reason: errorType,
+ automatic: true,
+ });
+ }
+
+ } catch (error) {
+ this.logger.error('Failed to handle throttle', LOG_CONTEXT, {
+ error: String(error), sessionId, accountId,
+ });
+ }
+ }
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 2254aec6f..6481c48da 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -59,6 +59,7 @@ import {
} from './ipc/handlers';
import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats';
import { AccountRegistry } from './accounts/account-registry';
+import { AccountThrottleHandler } from './accounts/account-throttle-handler';
import { getAccountStore } from './stores';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import {
@@ -227,6 +228,7 @@ let processManager: ProcessManager | null = null;
let webServer: WebServer | null = null;
let agentDetector: AgentDetector | null = null;
let accountRegistry: AccountRegistry | null = null;
+let accountThrottleHandler: AccountThrottleHandler | null = null;
// Create safeSend with dependency injection (Phase 2 refactoring)
const safeSend = createSafeSend(() => mainWindow);
@@ -346,9 +348,12 @@ app.whenReady().then(async () => {
logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup');
}
- // Initialize account registry for account multiplexing
+ // Initialize account registry and throttle handler for account multiplexing
try {
accountRegistry = new AccountRegistry(getAccountStore());
+ accountThrottleHandler = new AccountThrottleHandler(
+ accountRegistry, getStatsDB, safeSend, logger
+ );
logger.info('Account registry initialized', 'Startup');
} catch (error) {
logger.error(`Failed to initialize account registry: ${error}`, 'Startup');
@@ -701,6 +706,7 @@ function setupProcessListeners() {
},
getStatsDB,
getAccountRegistry: () => accountRegistry,
+ getThrottleHandler: () => accountThrottleHandler,
debugLog,
patterns: {
REGEX_MODERATOR_SESSION,
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index 17d22a199..e227b9fbd 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -184,6 +184,54 @@ export function createAccountsApi() {
ipcRenderer.on('account:limit-reached', wrappedHandler);
return () => ipcRenderer.removeListener('account:limit-reached', wrappedHandler);
},
+
+ /**
+ * Subscribe to account throttled events (rate limit detected)
+ * @param handler - Callback with throttle data
+ * @returns Cleanup function to unsubscribe
+ */
+ onThrottled: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:throttled', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:throttled', wrappedHandler);
+ },
+
+ /**
+ * Subscribe to account switch prompt events (user confirmation needed)
+ * @param handler - Callback with switch prompt data
+ * @returns Cleanup function to unsubscribe
+ */
+ onSwitchPrompt: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:switch-prompt', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:switch-prompt', wrappedHandler);
+ },
+
+ /**
+ * Subscribe to automatic account switch events (no confirmation needed)
+ * @param handler - Callback with switch execution data
+ * @returns Cleanup function to unsubscribe
+ */
+ onSwitchExecute: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:switch-execute', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:switch-execute', wrappedHandler);
+ },
+
+ /**
+ * Subscribe to account status change events (e.g., throttled -> active recovery)
+ * @param handler - Callback with status change data
+ * @returns Cleanup function to unsubscribe
+ */
+ onStatusChanged: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:status-changed', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:status-changed', wrappedHandler);
+ },
};
}
diff --git a/src/main/process-listeners/account-usage-listener.ts b/src/main/process-listeners/account-usage-listener.ts
index 1dc2313ca..7d6a02af4 100644
--- a/src/main/process-listeners/account-usage-listener.ts
+++ b/src/main/process-listeners/account-usage-listener.ts
@@ -42,6 +42,7 @@ export function setupAccountUsageListener(
getStatsDB: () => StatsDB;
safeSend: (channel: string, ...args: unknown[]) => void;
logger: {
+ info?: (message: string, context: string, data?: Record) => void;
error: (message: string, context: string, data?: Record) => void;
debug: (message: string, context: string, data?: Record) => void;
};
@@ -118,6 +119,21 @@ export function setupAccountUsageListener(
}
}
+ // Auto-recover from throttle if window has advanced past throttle point
+ if (account.status === 'throttled' && account.lastThrottledAt > 0) {
+ const timeSinceThrottle = now - account.lastThrottledAt;
+ if (timeSinceThrottle > windowMs) {
+ accountRegistry.setStatus(account.id, 'active');
+ safeSend('account:status-changed', {
+ accountId: account.id,
+ accountName: account.name,
+ oldStatus: 'throttled',
+ newStatus: 'active',
+ });
+ logger.info?.(`Account ${account.name} recovered from throttle`, LOG_CONTEXT);
+ }
+ }
+
// Update the account's lastUsedAt
accountRegistry.touchLastUsed(account.id);
diff --git a/src/main/process-listeners/error-listener.ts b/src/main/process-listeners/error-listener.ts
index 04ce341c9..0c2bc7e86 100644
--- a/src/main/process-listeners/error-listener.ts
+++ b/src/main/process-listeners/error-listener.ts
@@ -1,19 +1,28 @@
/**
* Agent error listener.
* Handles agent errors (auth expired, token exhaustion, rate limits, etc.).
+ * When account multiplexing is active, triggers throttle handling for
+ * rate_limited and auth_expired errors on sessions with account assignments.
*/
import type { ProcessManager } from '../process-manager';
import type { AgentError } from '../../shared/types';
import type { ProcessListenerDependencies } from './types';
+import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
+import type { AccountRegistry } from '../accounts/account-registry';
/**
* Sets up the agent-error listener.
* Handles logging and forwarding of agent errors to renderer.
+ * Optionally triggers throttle handling for account multiplexing.
*/
export function setupErrorListener(
processManager: ProcessManager,
- deps: Pick
+ deps: Pick,
+ accountDeps?: {
+ getAccountRegistry: () => AccountRegistry | null;
+ getThrottleHandler: () => AccountThrottleHandler | null;
+ }
): void {
const { safeSend, logger } = deps;
@@ -27,5 +36,22 @@ export function setupErrorListener(
recoverable: agentError.recoverable,
});
safeSend('agent:error', sessionId, agentError);
+
+ // Trigger throttle handling for rate-limited/auth-expired errors on sessions with accounts
+ if (accountDeps && (agentError.type === 'rate_limited' || agentError.type === 'auth_expired')) {
+ const accountRegistry = accountDeps.getAccountRegistry();
+ const throttleHandler = accountDeps.getThrottleHandler();
+ if (accountRegistry && throttleHandler) {
+ const assignment = accountRegistry.getAssignment(sessionId);
+ if (assignment) {
+ throttleHandler.handleThrottle({
+ sessionId,
+ accountId: assignment.accountId,
+ errorType: agentError.type,
+ errorMessage: agentError.message,
+ });
+ }
+ }
+ }
});
}
diff --git a/src/main/process-listeners/index.ts b/src/main/process-listeners/index.ts
index e258e451b..c35bd25b5 100644
--- a/src/main/process-listeners/index.ts
+++ b/src/main/process-listeners/index.ts
@@ -45,8 +45,11 @@ export function setupProcessListeners(
// Session ID listener (with group chat participant/moderator storage)
setupSessionIdListener(processManager, deps);
- // Agent error listener
- setupErrorListener(processManager, deps);
+ // Agent error listener (with optional account throttle handling)
+ setupErrorListener(processManager, deps, deps.getAccountRegistry ? {
+ getAccountRegistry: deps.getAccountRegistry,
+ getThrottleHandler: deps.getThrottleHandler ?? (() => null),
+ } : undefined);
// Stats/query-complete listener
setupStatsListener(processManager, deps);
diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts
index a0d3e91f1..f6439b2c2 100644
--- a/src/main/process-listeners/types.ts
+++ b/src/main/process-listeners/types.ts
@@ -9,6 +9,7 @@ import type { AgentDetector } from '../agents';
import type { SafeSendFn } from '../utils/safe-send';
import type { StatsDB } from '../stats';
import type { AccountRegistry } from '../accounts/account-registry';
+import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
import type { GroupChat, GroupChatParticipant } from '../group-chat/group-chat-storage';
import type { GroupChatMessage, GroupChatState } from '../../shared/group-chat-types';
import type { ParticipantState } from '../ipc/handlers/groupChat';
@@ -146,6 +147,8 @@ export interface ProcessListenerDependencies {
getStatsDB: () => StatsDB;
/** Account registry getter (optional — only needed for account multiplexing) */
getAccountRegistry?: () => AccountRegistry | null;
+ /** Account throttle handler getter (optional — only needed for account multiplexing) */
+ getThrottleHandler?: () => AccountThrottleHandler | null;
/** Debug log function */
debugLog: (prefix: string, message: string, ...args: unknown[]) => void;
/** Regex patterns */
From 918acb9961748b7e512a9de4d480c2c3b2fbfad8 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 10:23:25 -0500
Subject: [PATCH 09/59] MAESTRO: feat: add account switching execution with
process restart and resume
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements the core account switch mechanism (ACCT-MUX-10):
- AccountSwitcher service: kill process → reassign account → respawn with new CLAUDE_CONFIG_DIR → re-send last prompt
- Prompt recording in process:write handler for seamless resume after switch
- IPC handlers for execute-switch and cleanup-session
- Renderer respawn handler with toast notifications for switch lifecycle events
- Preload bridge with typed events (switch-started, switch-respawn, switch-completed, switch-failed)
- Session cleanup on agent deletion (removes account assignment and prompt tracking)
- Type declarations in global.d.ts for all new accounts API methods
- Comprehensive test suite for AccountSwitcher (7 tests)
Co-Authored-By: Claude Opus 4.6
---
.../main/accounts/account-switcher.test.ts | 239 ++++++++++++++++++
src/main/accounts/account-switcher.ts | 145 +++++++++++
src/main/ipc/handlers/accounts.ts | 47 +++-
src/main/ipc/handlers/process.ts | 14 +-
src/main/preload/accounts.ts | 57 +++++
src/renderer/App.tsx | 153 +++++++++++
src/renderer/components/SessionItem.tsx | 15 ++
.../UsageDashboard/UsageDashboardModal.tsx | 12 +-
src/renderer/global.d.ts | 10 +
9 files changed, 687 insertions(+), 5 deletions(-)
create mode 100644 src/__tests__/main/accounts/account-switcher.test.ts
create mode 100644 src/main/accounts/account-switcher.ts
diff --git a/src/__tests__/main/accounts/account-switcher.test.ts b/src/__tests__/main/accounts/account-switcher.test.ts
new file mode 100644
index 000000000..1ab0c4dbf
--- /dev/null
+++ b/src/__tests__/main/accounts/account-switcher.test.ts
@@ -0,0 +1,239 @@
+/**
+ * Tests for AccountSwitcher.
+ * Validates the account switch execution flow: kill → wait → reassign → respawn → notify.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { AccountSwitcher } from '../../../main/accounts/account-switcher';
+import type { ProcessManager } from '../../../main/process-manager/ProcessManager';
+import type { AccountRegistry } from '../../../main/accounts/account-registry';
+import type { AccountProfile } from '../../../shared/account-types';
+
+function createMockAccount(overrides: Partial = {}): AccountProfile {
+ return {
+ id: 'acct-1',
+ name: 'Test Account',
+ email: 'test@example.com',
+ configDir: '/home/test/.claude-test',
+ agentType: 'claude-code',
+ status: 'active',
+ authMethod: 'oauth',
+ addedAt: Date.now(),
+ lastUsedAt: Date.now(),
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ isDefault: true,
+ autoSwitchEnabled: true,
+ ...overrides,
+ };
+}
+
+describe('AccountSwitcher', () => {
+ let switcher: AccountSwitcher;
+ let mockProcessManager: {
+ kill: ReturnType;
+ };
+ let mockRegistry: {
+ get: ReturnType;
+ assignToSession: ReturnType;
+ };
+ let mockSafeSend: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+
+ mockProcessManager = {
+ kill: vi.fn().mockReturnValue(true),
+ };
+
+ mockRegistry = {
+ get: vi.fn(),
+ assignToSession: vi.fn(),
+ };
+
+ mockSafeSend = vi.fn();
+
+ switcher = new AccountSwitcher(
+ mockProcessManager as unknown as ProcessManager,
+ mockRegistry as unknown as AccountRegistry,
+ mockSafeSend,
+ );
+ });
+
+ it('should record and retrieve last prompts', () => {
+ switcher.recordLastPrompt('session-1', 'Hello, world');
+
+ // Internal state - verified indirectly via executeSwitch sending lastPrompt
+ switcher.cleanupSession('session-1');
+
+ // After cleanup, the prompt should be gone (no way to verify directly,
+ // but ensures no memory leak)
+ });
+
+ it('should execute a successful switch', async () => {
+ const fromAccount = createMockAccount({ id: 'acct-1', name: 'Account One' });
+ const toAccount = createMockAccount({ id: 'acct-2', name: 'Account Two', configDir: '/home/test/.claude-two' });
+
+ mockRegistry.get.mockImplementation((id: string) => {
+ if (id === 'acct-1') return fromAccount;
+ if (id === 'acct-2') return toAccount;
+ return null;
+ });
+
+ switcher.recordLastPrompt('session-1', 'Fix the bug');
+
+ const switchPromise = switcher.executeSwitch({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'throttled',
+ automatic: true,
+ });
+
+ // Advance past SWITCH_DELAY_MS (1000ms)
+ await vi.advanceTimersByTimeAsync(1100);
+
+ const result = await switchPromise;
+
+ expect(result).not.toBeNull();
+ expect(result!.sessionId).toBe('session-1');
+ expect(result!.fromAccountId).toBe('acct-1');
+ expect(result!.toAccountId).toBe('acct-2');
+ expect(result!.reason).toBe('throttled');
+ expect(result!.automatic).toBe(true);
+ expect(result!.timestamp).toBeGreaterThan(0);
+
+ // Verify process was killed
+ expect(mockProcessManager.kill).toHaveBeenCalledWith('session-1');
+
+ // Verify account assignment was updated
+ expect(mockRegistry.assignToSession).toHaveBeenCalledWith('session-1', 'acct-2');
+
+ // Verify switch-started notification
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-started', expect.objectContaining({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ toAccountName: 'Account Two',
+ }));
+
+ // Verify switch-respawn notification with lastPrompt
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', expect.objectContaining({
+ sessionId: 'session-1',
+ toAccountId: 'acct-2',
+ toAccountName: 'Account Two',
+ configDir: '/home/test/.claude-two',
+ lastPrompt: 'Fix the bug',
+ reason: 'throttled',
+ }));
+
+ // Verify switch-completed notification
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-completed', expect.objectContaining({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ fromAccountName: 'Account One',
+ toAccountName: 'Account Two',
+ }));
+ });
+
+ it('should return null when target account is not found', async () => {
+ mockRegistry.get.mockReturnValue(null);
+
+ const result = await switcher.executeSwitch({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-nonexistent',
+ reason: 'throttled',
+ automatic: true,
+ });
+
+ expect(result).toBeNull();
+ expect(mockProcessManager.kill).not.toHaveBeenCalled();
+ });
+
+ it('should continue even if process kill fails', async () => {
+ const toAccount = createMockAccount({ id: 'acct-2', name: 'Account Two', configDir: '/home/test/.claude-two' });
+ mockRegistry.get.mockImplementation((id: string) => {
+ if (id === 'acct-2') return toAccount;
+ return null;
+ });
+ mockProcessManager.kill.mockReturnValue(false);
+
+ const switchPromise = switcher.executeSwitch({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'throttled',
+ automatic: false,
+ });
+
+ await vi.advanceTimersByTimeAsync(1100);
+ const result = await switchPromise;
+
+ expect(result).not.toBeNull();
+ expect(mockProcessManager.kill).toHaveBeenCalledWith('session-1');
+ expect(mockRegistry.assignToSession).toHaveBeenCalledWith('session-1', 'acct-2');
+ });
+
+ it('should send null lastPrompt when no prompt was recorded', async () => {
+ const toAccount = createMockAccount({ id: 'acct-2', name: 'Account Two', configDir: '/home/test/.claude-two' });
+ mockRegistry.get.mockImplementation((id: string) => {
+ if (id === 'acct-2') return toAccount;
+ return null;
+ });
+
+ const switchPromise = switcher.executeSwitch({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'manual',
+ automatic: false,
+ });
+
+ await vi.advanceTimersByTimeAsync(1100);
+ await switchPromise;
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', expect.objectContaining({
+ lastPrompt: null,
+ }));
+ });
+
+ it('should send switch-failed notification on error', async () => {
+ mockRegistry.get.mockImplementation((id: string) => {
+ if (id === 'acct-2') return createMockAccount({ id: 'acct-2' });
+ return null;
+ });
+ mockProcessManager.kill.mockImplementation(() => {
+ throw new Error('Kill failed');
+ });
+
+ const result = await switcher.executeSwitch({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'throttled',
+ automatic: true,
+ });
+
+ expect(result).toBeNull();
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-failed', expect.objectContaining({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ error: expect.stringContaining('Kill failed'),
+ }));
+ });
+
+ it('should clean up session tracking data', () => {
+ switcher.recordLastPrompt('session-1', 'Some prompt');
+ switcher.recordLastPrompt('session-2', 'Another prompt');
+
+ switcher.cleanupSession('session-1');
+
+ // session-2 should still be tracked (verified indirectly)
+ // session-1 should be cleaned up
+ });
+});
diff --git a/src/main/accounts/account-switcher.ts b/src/main/accounts/account-switcher.ts
new file mode 100644
index 000000000..8d4ccd626
--- /dev/null
+++ b/src/main/accounts/account-switcher.ts
@@ -0,0 +1,145 @@
+/**
+ * Account Switcher Service
+ *
+ * Orchestrates the actual account switch for a session:
+ * 1. Kills the current agent process
+ * 2. Updates the session's account assignment
+ * 3. Sends respawn event to renderer (which handles spawn with --resume + new CLAUDE_CONFIG_DIR)
+ * 4. Notifies renderer of switch completion
+ */
+
+import type { ProcessManager } from '../process-manager/ProcessManager';
+import type { AccountRegistry } from './account-registry';
+import type { AccountSwitchEvent } from '../../shared/account-types';
+import type { SafeSendFn } from '../utils/safe-send';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'account-switcher';
+
+/** Delay between killing old process and sending respawn event (ms) */
+const SWITCH_DELAY_MS = 1000;
+
+export class AccountSwitcher {
+ /** Tracks the last user prompt per session for re-sending after switch */
+ private lastPrompts = new Map();
+
+ constructor(
+ private processManager: ProcessManager,
+ private accountRegistry: AccountRegistry,
+ private safeSend: SafeSendFn,
+ ) {}
+
+ /**
+ * Record the last user prompt sent to a session.
+ * Called by the process write handler so we can re-send after switching.
+ */
+ recordLastPrompt(sessionId: string, prompt: string): void {
+ this.lastPrompts.set(sessionId, prompt);
+ }
+
+ /**
+ * Execute an account switch for a session.
+ * 1. Kill the current agent process
+ * 2. Update the session's account assignment
+ * 3. Restart with --resume using the new account's CLAUDE_CONFIG_DIR
+ * 4. Re-send the last user prompt
+ *
+ * Returns the switch event on success, or null on failure.
+ */
+ async executeSwitch(params: {
+ sessionId: string;
+ fromAccountId: string;
+ toAccountId: string;
+ reason: AccountSwitchEvent['reason'];
+ automatic: boolean;
+ }): Promise {
+ const { sessionId, fromAccountId, toAccountId, reason, automatic } = params;
+
+ try {
+ const toAccount = this.accountRegistry.get(toAccountId);
+ if (!toAccount) {
+ logger.error('Target account not found', LOG_CONTEXT, { toAccountId });
+ return null;
+ }
+
+ const fromAccount = this.accountRegistry.get(fromAccountId);
+ const lastPrompt = this.lastPrompts.get(sessionId);
+
+ logger.info(`Switching session ${sessionId} from ${fromAccount?.name ?? fromAccountId} to ${toAccount.name}`, LOG_CONTEXT);
+
+ // Notify renderer that switch is starting
+ this.safeSend('account:switch-started', {
+ sessionId,
+ fromAccountId,
+ toAccountId,
+ toAccountName: toAccount.name,
+ });
+
+ // 1. Kill the current agent process
+ const killed = this.processManager.kill(sessionId);
+ if (!killed) {
+ logger.warn('Could not kill process (may have already exited)', LOG_CONTEXT, { sessionId });
+ }
+
+ // Wait for process cleanup
+ await new Promise(resolve => setTimeout(resolve, SWITCH_DELAY_MS));
+
+ // 2. Update the account assignment
+ this.accountRegistry.assignToSession(sessionId, toAccountId);
+
+ // 3. Send respawn event to renderer with the new account config.
+ // The renderer has access to the full session config and will call process:spawn
+ // with the correct parameters including --resume and the new CLAUDE_CONFIG_DIR.
+ this.safeSend('account:switch-respawn', {
+ sessionId,
+ toAccountId,
+ toAccountName: toAccount.name,
+ configDir: toAccount.configDir,
+ lastPrompt: lastPrompt ?? null,
+ reason,
+ });
+
+ // 4. Create the switch event
+ const switchEvent: AccountSwitchEvent = {
+ sessionId,
+ fromAccountId,
+ toAccountId,
+ reason,
+ automatic,
+ timestamp: Date.now(),
+ };
+
+ // Notify renderer that switch is complete
+ this.safeSend('account:switch-completed', {
+ ...switchEvent,
+ fromAccountName: fromAccount?.name ?? fromAccountId,
+ toAccountName: toAccount.name,
+ });
+
+ logger.info(`Account switch completed for session ${sessionId}`, LOG_CONTEXT, {
+ from: fromAccount?.name, to: toAccount.name, reason,
+ });
+
+ return switchEvent;
+
+ } catch (error) {
+ logger.error('Account switch failed', LOG_CONTEXT, {
+ error: String(error), sessionId, fromAccountId, toAccountId,
+ });
+
+ this.safeSend('account:switch-failed', {
+ sessionId,
+ fromAccountId,
+ toAccountId,
+ error: String(error),
+ });
+
+ return null;
+ }
+ }
+
+ /** Clean up tracking data when a session is closed */
+ cleanupSession(sessionId: string): void {
+ this.lastPrompts.delete(sessionId);
+ }
+}
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index 56e12850b..38d013b38 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -12,7 +12,8 @@
import { ipcMain } from 'electron';
import type { AccountRegistry } from '../../accounts/account-registry';
-import type { AccountSwitchConfig } from '../../../shared/account-types';
+import type { AccountSwitcher } from '../../accounts/account-switcher';
+import type { AccountSwitchConfig, AccountSwitchEvent } from '../../../shared/account-types';
import { getStatsDB } from '../../stats';
import { logger } from '../../utils/logger';
import {
@@ -34,13 +35,14 @@ const LOG_CONTEXT = '[Accounts]';
*/
export interface AccountHandlerDependencies {
getAccountRegistry: () => AccountRegistry | null;
+ getAccountSwitcher?: () => AccountSwitcher | null;
}
/**
* Register all account multiplexing IPC handlers.
*/
export function registerAccountHandlers(deps: AccountHandlerDependencies): void {
- const { getAccountRegistry } = deps;
+ const { getAccountRegistry, getAccountSwitcher } = deps;
/** Get the account registry or throw if not initialized */
function requireRegistry(): AccountRegistry {
@@ -331,4 +333,45 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
return { exists: false, hasAuth: false, symlinksValid: false, error: String(error) };
}
});
+
+ // --- Session Cleanup ---
+
+ ipcMain.handle('accounts:cleanup-session', async (_event, sessionId: string) => {
+ try {
+ const registry = getAccountRegistry();
+ if (registry) {
+ registry.removeAssignment(sessionId);
+ }
+ const switcher = getAccountSwitcher?.();
+ if (switcher) {
+ switcher.cleanupSession(sessionId);
+ }
+ return { success: true };
+ } catch (error) {
+ logger.error('cleanup session error', LOG_CONTEXT, { error: String(error), sessionId });
+ return { success: false, error: String(error) };
+ }
+ });
+
+ // --- Account Switching ---
+
+ ipcMain.handle('accounts:execute-switch', async (_event, params: {
+ sessionId: string;
+ fromAccountId: string;
+ toAccountId: string;
+ reason: AccountSwitchEvent['reason'];
+ automatic: boolean;
+ }) => {
+ try {
+ const switcher = getAccountSwitcher?.();
+ if (!switcher) {
+ return { success: false, error: 'Account switcher not initialized' };
+ }
+ const result = await switcher.executeSwitch(params);
+ return { success: !!result, event: result };
+ } catch (error) {
+ logger.error('execute switch error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
}
diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts
index 8404e64d3..94ab1362f 100644
--- a/src/main/ipc/handlers/process.ts
+++ b/src/main/ipc/handlers/process.ts
@@ -3,6 +3,7 @@ import Store from 'electron-store';
import * as os from 'os';
import { ProcessManager } from '../../process-manager';
import { AgentDetector } from '../../agents';
+import type { AccountSwitcher } from '../../accounts/account-switcher';
import { logger } from '../../utils/logger';
import { addBreadcrumb } from '../../utils/sentry';
import { isWebContentsAvailable } from '../../utils/safe-send';
@@ -57,6 +58,7 @@ export interface ProcessHandlerDependencies {
settingsStore: Store;
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<{ sessions: any[] }>;
+ getAccountSwitcher?: () => AccountSwitcher | null;
}
/**
@@ -72,7 +74,7 @@ export interface ProcessHandlerDependencies {
* - runCommand: Execute a single command and capture output
*/
export function registerProcessHandlers(deps: ProcessHandlerDependencies): void {
- const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } =
+ const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow, getAccountSwitcher } =
deps;
// Spawn a new process for a session
@@ -531,7 +533,15 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
sessionId,
dataLength: data.length,
});
- return processManager.write(sessionId, data);
+ const result = processManager.write(sessionId, data);
+
+ // Record the last prompt for account switching resume
+ const accountSwitcher = getAccountSwitcher?.();
+ if (accountSwitcher) {
+ accountSwitcher.recordLastPrompt(sessionId, data);
+ }
+
+ return result;
})
);
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index e227b9fbd..e42e7bba6 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -232,6 +232,63 @@ export function createAccountsApi() {
ipcRenderer.on('account:status-changed', wrappedHandler);
return () => ipcRenderer.removeListener('account:status-changed', wrappedHandler);
},
+
+ // --- Session Cleanup ---
+
+ /** Clean up account data when a session is closed */
+ cleanupSession: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
+ ipcRenderer.invoke('accounts:cleanup-session', sessionId),
+
+ // --- Account Switching ---
+
+ /** Execute an account switch for a session */
+ executeSwitch: (params: {
+ sessionId: string;
+ fromAccountId: string;
+ toAccountId: string;
+ reason: string;
+ automatic: boolean;
+ }): Promise<{ success: boolean; event?: unknown; error?: string }> =>
+ ipcRenderer.invoke('accounts:execute-switch', params),
+
+ /** Subscribe to switch-started events */
+ onSwitchStarted: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:switch-started', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:switch-started', wrappedHandler);
+ },
+
+ /** Subscribe to switch-respawn events (renderer must respawn the agent) */
+ onSwitchRespawn: (handler: (data: {
+ sessionId: string;
+ toAccountId: string;
+ toAccountName: string;
+ configDir: string;
+ lastPrompt: string | null;
+ reason: string;
+ }) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) =>
+ handler(data);
+ ipcRenderer.on('account:switch-respawn', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:switch-respawn', wrappedHandler);
+ },
+
+ /** Subscribe to switch-completed events */
+ onSwitchCompleted: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:switch-completed', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:switch-completed', wrappedHandler);
+ },
+
+ /** Subscribe to switch-failed events */
+ onSwitchFailed: (handler: (data: Record) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: Record) =>
+ handler(data);
+ ipcRenderer.on('account:switch-failed', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:switch-failed', wrappedHandler);
+ },
};
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index d30235544..6d1f6e4b9 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -2129,6 +2129,152 @@ function MaestroConsoleInner() {
};
}, []);
+ // Subscribe to account limit warning/reached events for toast notifications
+ useEffect(() => {
+ const unsubWarning = window.maestro.accounts.onLimitWarning((data) => {
+ addToastRef.current({
+ type: 'warning',
+ title: 'Account Limit Warning',
+ message: `Account ${data.accountName} is at ${Math.round(data.usagePercent)}% of its token limit`,
+ duration: 10_000,
+ });
+ });
+
+ const unsubReached = window.maestro.accounts.onLimitReached((data) => {
+ addToastRef.current({
+ type: 'error',
+ title: 'Account Limit Reached',
+ message: `Account ${data.accountName} has reached its token limit (${Math.round(data.usagePercent)}%)`,
+ duration: 0, // Do NOT auto-dismiss
+ });
+ });
+
+ return () => {
+ unsubWarning();
+ unsubReached();
+ };
+ }, []);
+
+ // Subscribe to account switch events (respawn agent with new account after switch)
+ useEffect(() => {
+ const unsubRespawn = window.maestro.accounts.onSwitchRespawn(async (data) => {
+ const { sessionId: switchSessionId, toAccountId, toAccountName, configDir, lastPrompt, reason } = data;
+
+ // Find the session that needs respawning (match by base session ID)
+ const session = sessionsRef.current.find(s => switchSessionId.startsWith(s.id));
+ if (!session) {
+ console.error('[AccountSwitch] Session not found for respawn:', switchSessionId);
+ return;
+ }
+
+ // Update session with new account info and CLAUDE_CONFIG_DIR
+ setSessions((prev) =>
+ prev.map(s => {
+ if (s.id !== session.id) return s;
+ return {
+ ...s,
+ accountId: toAccountId,
+ accountName: toAccountName,
+ customEnvVars: {
+ ...s.customEnvVars,
+ CLAUDE_CONFIG_DIR: configDir,
+ },
+ };
+ })
+ );
+
+ try {
+ // Get agent config for respawn
+ const agent = await window.maestro.agents.get(session.toolType);
+ if (!agent) {
+ console.error('[AccountSwitch] Agent not found for respawn:', session.toolType);
+ return;
+ }
+
+ // Get the active tab's agent session ID for --resume
+ const tab = getActiveTab(session);
+ const tabAgentSessionId = tab?.agentSessionId;
+ const commandToUse = agent.path ?? agent.command;
+ const agentArgs = agent.args ?? [];
+
+ // Determine the target session ID (with tab suffix)
+ const targetSessionId = `${session.id}-ai-${tab?.id || 'default'}`;
+
+ // Spawn with --resume and updated env vars
+ await window.maestro.process.spawn({
+ sessionId: targetSessionId,
+ toolType: session.toolType,
+ cwd: session.cwd,
+ command: commandToUse,
+ args: agentArgs,
+ agentSessionId: tabAgentSessionId ?? undefined,
+ sessionCustomPath: session.customPath,
+ sessionCustomArgs: session.customArgs,
+ sessionCustomEnvVars: {
+ ...session.customEnvVars,
+ CLAUDE_CONFIG_DIR: configDir,
+ },
+ sessionCustomModel: session.customModel,
+ sessionCustomContextWindow: session.customContextWindow,
+ sessionSshRemoteConfig: session.sessionSshRemoteConfig,
+ });
+
+ // Re-send the last prompt after a delay to allow the agent to initialize
+ if (lastPrompt) {
+ setTimeout(() => {
+ window.maestro.process.write(targetSessionId, lastPrompt);
+ }, 2000);
+ }
+
+ addToastRef.current({
+ type: 'info',
+ title: 'Account Switched',
+ message: `Switched to account ${toAccountName} (${reason})`,
+ duration: 5_000,
+ });
+ } catch (error) {
+ console.error('[AccountSwitch] Failed to respawn agent:', error);
+ addToastRef.current({
+ type: 'error',
+ title: 'Account Switch Failed',
+ message: `Failed to respawn agent after account switch: ${String(error)}`,
+ duration: 0,
+ });
+ }
+ });
+
+ const unsubSwitchFailed = window.maestro.accounts.onSwitchFailed((data) => {
+ addToastRef.current({
+ type: 'error',
+ title: 'Account Switch Failed',
+ message: `Failed to switch account: ${data.error || 'Unknown error'}`,
+ duration: 0,
+ });
+ });
+
+ const unsubSwitchExecute = window.maestro.accounts.onSwitchExecute(async (data) => {
+ // Auto-switch mode: execute the switch immediately
+ const { sessionId: switchSessionId, fromAccountId, toAccountId, reason } = data as any;
+ try {
+ await window.maestro.accounts.executeSwitch({
+ sessionId: switchSessionId,
+ fromAccountId,
+ toAccountId,
+ reason: reason ?? 'throttled',
+ automatic: true,
+ });
+ } catch (error) {
+ console.error('[AccountSwitch] Auto-switch execution failed:', error);
+ }
+ });
+
+ return () => {
+ unsubRespawn();
+ unsubSwitchFailed();
+ unsubSwitchExecute();
+ };
+ }, []);
+
// Keyboard navigation state
// Note: selectedSidebarIndex/setSelectedSidebarIndex are destructured from useUIStore() above
// Note: activeTab is memoized later at line ~3795 - use that for all tab operations
@@ -7658,6 +7804,13 @@ You are taking over this conversation. Based on the context above, provide a bri
console.error('Failed to delete playbooks:', error);
}
+ // Clean up account assignment and switcher tracking
+ try {
+ await window.maestro.accounts.cleanupSession(id);
+ } catch (error) {
+ console.error('Failed to clean up account session:', error);
+ }
+
// If this is a worktree session, track its path to prevent re-discovery
if (session.worktreeParentPath && session.cwd) {
setRemovedWorktreePaths((prev) => new Set([...prev, session.cwd]));
diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx
index 2554071e4..8d1c2229c 100644
--- a/src/renderer/components/SessionItem.tsx
+++ b/src/renderer/components/SessionItem.tsx
@@ -176,6 +176,21 @@ export const SessionItem = memo(function SessionItem({
)}
{session.toolType}
{session.sessionSshRemoteConfig?.enabled ? ' (SSH)' : ''}
+ {/* Account assignment badge */}
+ {session.accountId && session.accountName && (
+
+ {session.accountName.split('@')[0]?.slice(0, 3)?.toUpperCase() || 'ACC'}
+
+ )}
{/* Group badge (only in bookmark variant when session belongs to a group) */}
{variant === 'bookmark' && group && (
>
)}
+
+ {viewMode === 'accounts' && (
+
+ )}
)}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 2b591d601..4ced8c690 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2637,6 +2637,16 @@ interface MaestroAPI {
onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number; totalTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void;
onLimitWarning: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
onLimitReached: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
+ onThrottled: (handler: (data: Record) => void) => () => void;
+ onSwitchPrompt: (handler: (data: Record) => void) => () => void;
+ onSwitchExecute: (handler: (data: Record) => void) => () => void;
+ onStatusChanged: (handler: (data: Record) => void) => () => void;
+ cleanupSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
+ executeSwitch: (params: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean }) => Promise<{ success: boolean; event?: unknown; error?: string }>;
+ onSwitchStarted: (handler: (data: Record) => void) => () => void;
+ onSwitchRespawn: (handler: (data: { sessionId: string; toAccountId: string; toAccountName: string; configDir: string; lastPrompt: string | null; reason: string }) => void) => () => void;
+ onSwitchCompleted: (handler: (data: Record) => void) => () => void;
+ onSwitchFailed: (handler: (data: Record) => void) => () => void;
};
// Director's Notes API (unified history + synopsis generation)
From df40e238e9c522f7a5042966752dc1a92e7d2a89 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 10:41:03 -0500
Subject: [PATCH 10/59] MAESTRO: feat: add account switch confirmation modal
and manual account selector
Implements the UI layer for account multiplexing switches:
- AccountSwitchModal: confirmation modal for prompted switches (throttle/limit events)
with reason-based headers, account status display, and action buttons
- AccountSelector: compact dropdown in InputArea footer for manual account switching
per session, with status dots, usage bars, and Manage Accounts link
- App.tsx: onSwitchPrompt listener shows modal, onSwitchCompleted toast notification
- Added ACCOUNT_SWITCH priority (1005) to modalPriorities.ts
- Added 'accounts' to SettingsTab type for View All Accounts navigation
Co-Authored-By: Claude Opus 4.6
---
.../components/AccountSelector.test.ts | 14 +
.../components/AccountSwitchModal.test.ts | 53 ++++
src/renderer/App.tsx | 68 +++++
src/renderer/components/AccountSelector.tsx | 245 ++++++++++++++++++
.../components/AccountSwitchModal.tsx | 209 +++++++++++++++
src/renderer/components/InputArea.tsx | 24 ++
src/renderer/constants/modalPriorities.ts | 3 +
src/renderer/types/index.ts | 2 +-
8 files changed, 617 insertions(+), 1 deletion(-)
create mode 100644 src/__tests__/renderer/components/AccountSelector.test.ts
create mode 100644 src/__tests__/renderer/components/AccountSwitchModal.test.ts
create mode 100644 src/renderer/components/AccountSelector.tsx
create mode 100644 src/renderer/components/AccountSwitchModal.tsx
diff --git a/src/__tests__/renderer/components/AccountSelector.test.ts b/src/__tests__/renderer/components/AccountSelector.test.ts
new file mode 100644
index 000000000..231ce50ea
--- /dev/null
+++ b/src/__tests__/renderer/components/AccountSelector.test.ts
@@ -0,0 +1,14 @@
+/**
+ * @file AccountSelector.test.ts
+ * @description Tests for AccountSelector component exports
+ */
+
+import { describe, it, expect } from 'vitest';
+
+describe('AccountSelector', () => {
+ it('should export the component', async () => {
+ const mod = await import('../../../renderer/components/AccountSelector');
+ expect(mod.AccountSelector).toBeDefined();
+ expect(typeof mod.AccountSelector).toBe('function');
+ });
+});
diff --git a/src/__tests__/renderer/components/AccountSwitchModal.test.ts b/src/__tests__/renderer/components/AccountSwitchModal.test.ts
new file mode 100644
index 000000000..3df95fc9d
--- /dev/null
+++ b/src/__tests__/renderer/components/AccountSwitchModal.test.ts
@@ -0,0 +1,53 @@
+/**
+ * @file AccountSwitchModal.test.ts
+ * @description Tests for AccountSwitchModal component exports and types
+ */
+
+import { describe, it, expect } from 'vitest';
+
+describe('AccountSwitchModal', () => {
+ it('should export the component', async () => {
+ const mod = await import('../../../renderer/components/AccountSwitchModal');
+ expect(mod.AccountSwitchModal).toBeDefined();
+ expect(typeof mod.AccountSwitchModal).toBe('function');
+ });
+
+ it('should return null when isOpen is false', async () => {
+ const mod = await import('../../../renderer/components/AccountSwitchModal');
+ const result = mod.AccountSwitchModal({
+ theme: {
+ id: 'dracula',
+ name: 'Dracula',
+ mode: 'dark',
+ colors: {
+ bgMain: '#282a36',
+ bgSidebar: '#21222c',
+ bgActivity: '#44475a',
+ border: '#6272a4',
+ textMain: '#f8f8f2',
+ textDim: '#6272a4',
+ accent: '#bd93f9',
+ accentDim: '#bd93f920',
+ accentText: '#bd93f9',
+ accentForeground: '#ffffff',
+ success: '#50fa7b',
+ warning: '#f1fa8c',
+ error: '#ff5555',
+ },
+ },
+ isOpen: false,
+ onClose: () => {},
+ switchData: {
+ sessionId: 'test',
+ fromAccountId: 'a1',
+ fromAccountName: 'Account 1',
+ toAccountId: 'a2',
+ toAccountName: 'Account 2',
+ reason: 'throttled',
+ },
+ onConfirmSwitch: () => {},
+ onViewDashboard: () => {},
+ });
+ expect(result).toBeNull();
+ });
+});
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 6d1f6e4b9..b702f694a 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -44,6 +44,7 @@ import { TourOverlay } from './components/Wizard/tour';
import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges';
import { EmptyStateView } from './components/EmptyStateView';
import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal';
+import { AccountSwitchModal } from './components/AccountSwitchModal';
// Lazy-loaded components for performance (rarely-used heavy modals)
// These are loaded on-demand when the user first opens them
@@ -889,6 +890,18 @@ function MaestroConsoleInner() {
const [deleteAgentModalOpen, setDeleteAgentModalOpen] = useState(false);
const [deleteAgentSession, setDeleteAgentSession] = useState(null);
+ // Account Switch Prompt Modal State
+ const [switchPromptData, setSwitchPromptData] = useState<{
+ sessionId: string;
+ fromAccountId: string;
+ fromAccountName: string;
+ toAccountId: string;
+ toAccountName: string;
+ reason: string;
+ tokensAtThrottle?: number;
+ usagePercent?: number;
+ } | null>(null);
+
// Note: Git Diff State, Tour Overlay State, and Git Log Viewer State are from modalStore
// Note: Renaming state (editingGroupId/editingSessionId) and drag state (draggingSessionId)
@@ -2275,6 +2288,36 @@ function MaestroConsoleInner() {
};
}, []);
+ // Subscribe to account switch prompt events (user confirmation needed)
+ useEffect(() => {
+ const unsubSwitchPrompt = window.maestro.accounts.onSwitchPrompt((data: any) => {
+ setSwitchPromptData({
+ sessionId: data.sessionId,
+ fromAccountId: data.fromAccountId,
+ fromAccountName: data.fromAccountName ?? data.fromAccountId,
+ toAccountId: data.toAccountId,
+ toAccountName: data.toAccountName ?? data.toAccountId,
+ reason: data.reason ?? 'throttled',
+ tokensAtThrottle: data.tokensAtThrottle,
+ usagePercent: data.usagePercent,
+ });
+ });
+
+ const unsubSwitchCompleted = window.maestro.accounts.onSwitchCompleted((data: any) => {
+ addToastRef.current({
+ type: 'success',
+ title: 'Account Switched',
+ message: `Switched from ${data.fromAccountName ?? data.fromAccountId} to ${data.toAccountName ?? data.toAccountId}`,
+ duration: 5_000,
+ });
+ });
+
+ return () => {
+ unsubSwitchPrompt();
+ unsubSwitchCompleted();
+ };
+ }, []);
+
// Keyboard navigation state
// Note: selectedSidebarIndex/setSelectedSidebarIndex are destructured from useUIStore() above
// Note: activeTab is memoized later at line ~3795 - use that for all tab operations
@@ -12516,6 +12559,31 @@ You are taking over this conversation. Based on the context above, provide a bri
/>
)}
+ {/* Account Switch Confirmation Modal */}
+ {switchPromptData && (
+ setSwitchPromptData(null)}
+ switchData={switchPromptData}
+ onConfirmSwitch={async () => {
+ await window.maestro.accounts.executeSwitch({
+ sessionId: switchPromptData.sessionId,
+ fromAccountId: switchPromptData.fromAccountId,
+ toAccountId: switchPromptData.toAccountId,
+ reason: switchPromptData.reason,
+ automatic: false,
+ });
+ setSwitchPromptData(null);
+ }}
+ onViewDashboard={() => {
+ setSwitchPromptData(null);
+ setSettingsModalOpen(true);
+ setSettingsTab('accounts');
+ }}
+ />
+ )}
+
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
{sessions.length === 0 && !isMobileLandscape ? (
void;
+ onManageAccounts?: () => void;
+ compact?: boolean;
+}
+
+function getStatusColor(status: string, theme: Theme): string {
+ switch (status) {
+ case 'active':
+ return theme.colors.success;
+ case 'throttled':
+ return theme.colors.warning;
+ case 'expired':
+ case 'disabled':
+ return theme.colors.error;
+ default:
+ return theme.colors.textDim;
+ }
+}
+
+export function AccountSelector({
+ theme,
+ sessionId: _sessionId,
+ currentAccountId,
+ onSwitchAccount,
+ onManageAccounts,
+ compact = false,
+}: AccountSelectorProps) {
+ const [accounts, setAccounts] = useState([]);
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // Fetch accounts when dropdown opens
+ useEffect(() => {
+ if (!isOpen) return;
+ let cancelled = false;
+ (async () => {
+ try {
+ const list = (await window.maestro.accounts.list()) as AccountProfile[];
+ if (!cancelled) setAccounts(list);
+ } catch {
+ // Silently fail - dropdown will show empty
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [isOpen]);
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleClick = (e: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, [isOpen]);
+
+ // Close on Escape
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('keydown', handleKey, true);
+ return () => document.removeEventListener('keydown', handleKey, true);
+ }, [isOpen]);
+
+ const currentAccount = accounts.find((a) => a.id === currentAccountId);
+ const displayName = currentAccount?.name ?? currentAccount?.email ?? 'No Account';
+
+ const handleSelect = useCallback(
+ (accountId: string) => {
+ if (accountId !== currentAccountId) {
+ onSwitchAccount(accountId);
+ }
+ setIsOpen(false);
+ },
+ [currentAccountId, onSwitchAccount]
+ );
+
+ return (
+
+ {/* Trigger button */}
+ {compact ? (
+
setIsOpen((v) => !v)}
+ className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full cursor-pointer transition-all opacity-50 hover:opacity-100"
+ style={{
+ color: currentAccountId ? theme.colors.accent : theme.colors.textDim,
+ backgroundColor: currentAccountId ? `${theme.colors.accent}15` : 'transparent',
+ border: currentAccountId
+ ? `1px solid ${theme.colors.accent}40`
+ : '1px solid transparent',
+ }}
+ title={currentAccountId ? `Account: ${displayName}` : 'Select account'}
+ >
+
+ {currentAccountId && (
+ {displayName.split('@')[0]}
+ )}
+
+ ) : (
+
setIsOpen((v) => !v)}
+ className="flex items-center gap-1.5 text-xs px-2 py-1 rounded border transition-colors hover:bg-white/5"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+
+ {displayName}
+
+
+ )}
+
+ {/* Dropdown */}
+ {isOpen && (
+
+
+ {accounts.length === 0 && (
+
+ No accounts configured
+
+ )}
+ {accounts.map((account) => {
+ const isCurrent = account.id === currentAccountId;
+ const statusColor = getStatusColor(account.status, theme);
+ return (
+
handleSelect(account.id)}
+ className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-white/5"
+ style={{
+ backgroundColor: isCurrent ? `${theme.colors.accent}10` : undefined,
+ }}
+ >
+ {/* Status dot */}
+
+ {/* Account info */}
+
+
+ {account.name || account.email}
+
+ {/* Usage bar if limit configured */}
+ {account.tokenLimitPerWindow > 0 && (
+
+ )}
+
+ {/* Current indicator */}
+ {isCurrent && (
+
+ active
+
+ )}
+
+ );
+ })}
+
+ {/* Manage Accounts link */}
+ {onManageAccounts && (
+
+ {
+ setIsOpen(false);
+ onManageAccounts();
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-white/5"
+ style={{ color: theme.colors.textDim }}
+ >
+
+ Manage Accounts
+
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/AccountSwitchModal.tsx b/src/renderer/components/AccountSwitchModal.tsx
new file mode 100644
index 000000000..0a7a713b0
--- /dev/null
+++ b/src/renderer/components/AccountSwitchModal.tsx
@@ -0,0 +1,209 @@
+/**
+ * AccountSwitchModal - Confirmation modal for prompted account switches
+ *
+ * Appears when `promptBeforeSwitch` is true and a throttle/limit event triggers
+ * an account switch suggestion. Shows current account status, recommended switch
+ * target, and action buttons to confirm, dismiss, or view the dashboard.
+ */
+
+import React from 'react';
+import { AlertTriangle, ArrowRightLeft, BarChart3 } from 'lucide-react';
+import type { Theme } from '../types';
+import { Modal } from './ui/Modal';
+import { MODAL_PRIORITIES } from '../constants/modalPriorities';
+
+export interface AccountSwitchModalProps {
+ theme: Theme;
+ isOpen: boolean;
+ onClose: () => void;
+ switchData: {
+ sessionId: string;
+ fromAccountId: string;
+ fromAccountName: string;
+ toAccountId: string;
+ toAccountName: string;
+ reason: string;
+ tokensAtThrottle?: number;
+ usagePercent?: number;
+ };
+ onConfirmSwitch: () => void;
+ onConfirmSwitchAndResume?: () => void;
+ onViewDashboard: () => void;
+}
+
+function getReasonHeader(reason: string): string {
+ switch (reason) {
+ case 'throttled':
+ return 'Account Throttled';
+ case 'limit-approaching':
+ case 'limit-reached':
+ return 'Account Limit Reached';
+ case 'auth-expired':
+ return 'Authentication Expired';
+ default:
+ return 'Account Switch Recommended';
+ }
+}
+
+function getReasonDescription(reason: string, name: string, usagePercent?: number): string {
+ switch (reason) {
+ case 'throttled':
+ return `Account ${name} has been rate limited`;
+ case 'limit-approaching':
+ return `Account ${name} is at ${usagePercent != null ? Math.round(usagePercent) : '?'}% of its token limit`;
+ case 'limit-reached':
+ return `Account ${name} has reached its token limit (${usagePercent != null ? Math.round(usagePercent) : '?'}%)`;
+ case 'auth-expired':
+ return `Account ${name} authentication has expired`;
+ default:
+ return `Account ${name} needs to be switched`;
+ }
+}
+
+function getStatusColor(reason: string, theme: Theme): string {
+ switch (reason) {
+ case 'throttled':
+ return theme.colors.warning;
+ case 'limit-approaching':
+ return theme.colors.warning;
+ case 'limit-reached':
+ case 'auth-expired':
+ return theme.colors.error;
+ default:
+ return theme.colors.textDim;
+ }
+}
+
+export function AccountSwitchModal({
+ theme,
+ isOpen,
+ onClose,
+ switchData,
+ onConfirmSwitch,
+ onConfirmSwitchAndResume,
+ onViewDashboard,
+}: AccountSwitchModalProps) {
+ if (!isOpen) return null;
+
+ const { fromAccountName, toAccountName, reason, usagePercent } = switchData;
+
+ return (
+ }
+ width={440}
+ closeOnBackdropClick
+ footer={
+
+
+
+ View All Accounts
+
+
+
+ Stay on Current
+
+ {onConfirmSwitchAndResume && (
+
+ Switch & Resume
+
+ )}
+
+ Switch Account
+
+
+ }
+ >
+
+ {/* Reason explanation */}
+
+ {getReasonDescription(reason, fromAccountName, usagePercent)}
+
+
+ {/* Current account */}
+
+
+
+
+ {fromAccountName}
+
+
+ Current account
+ {usagePercent != null && ` \u00B7 ${Math.round(usagePercent)}% used`}
+
+
+
+
+ {/* Arrow */}
+
+
+ {/* Recommended account */}
+
+
+
+
+ {toAccountName}
+
+
+ Recommended switch target
+
+
+
+
+
+ );
+}
diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx
index db6c09573..5b7dd1f59 100644
--- a/src/renderer/components/InputArea.tsx
+++ b/src/renderer/components/InputArea.tsx
@@ -34,6 +34,7 @@ import { SummarizeProgressOverlay } from './SummarizeProgressOverlay';
import { WizardInputPanel } from './InlineWizard';
import { useAgentCapabilities, useScrollIntoView } from '../hooks';
import { getProviderDisplayName } from '../utils/sessionValidation';
+import { AccountSelector } from './AccountSelector';
interface SlashCommand {
command: string;
@@ -997,6 +998,29 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
+ {/* Account selector - AI mode + claude-code only */}
+ {session.inputMode === 'ai' && session.toolType === 'claude-code' && (
+
{
+ const currentAccountId = session.accountId;
+ if (currentAccountId && currentAccountId !== toAccountId) {
+ await window.maestro.accounts.executeSwitch({
+ sessionId: session.id,
+ fromAccountId: currentAccountId,
+ toAccountId,
+ reason: 'manual',
+ automatic: false,
+ });
+ } else {
+ await window.maestro.accounts.assign(session.id, toAccountId);
+ }
+ }}
+ compact
+ />
+ )}
{/* Save to History toggle - AI mode only */}
{session.inputMode === 'ai' && onToggleTabSaveToHistory && (
Date: Sun, 15 Feb 2026 11:01:18 -0500
Subject: [PATCH 11/59] MAESTRO: feat: integrate account multiplexing into all
agent spawn paths
Wire CLAUDE_CONFIG_DIR injection into every code path that spawns Claude Code
agents: standard process:spawn, Group Chat participants/moderators/synthesis,
Context Grooming, and session resume. Add missing onAssigned type declaration
to global.d.ts to fix lint error.
Co-Authored-By: Claude Opus 4.6
---
src/main/accounts/account-env-injector.ts | 93 +++++++++++++++++++++++
src/main/group-chat/group-chat-agent.ts | 23 +++++-
src/main/group-chat/group-chat-router.ts | 90 ++++++++++++++++++++--
src/main/group-chat/group-chat-storage.ts | 2 +
src/main/index.ts | 8 ++
src/main/ipc/handlers/process.ts | 27 ++++++-
src/main/preload/accounts.ts | 12 +++
src/main/utils/context-groomer.ts | 26 ++++++-
src/renderer/App.tsx | 27 +++++++
src/renderer/global.d.ts | 1 +
10 files changed, 301 insertions(+), 8 deletions(-)
create mode 100644 src/main/accounts/account-env-injector.ts
diff --git a/src/main/accounts/account-env-injector.ts b/src/main/accounts/account-env-injector.ts
new file mode 100644
index 000000000..4d3fac7b5
--- /dev/null
+++ b/src/main/accounts/account-env-injector.ts
@@ -0,0 +1,93 @@
+/**
+ * Account Environment Injector
+ *
+ * Shared utility for injecting CLAUDE_CONFIG_DIR into spawn environments.
+ * Called by ALL code paths that spawn Claude Code agents:
+ * - Standard process:spawn handler
+ * - Group Chat participants and moderators
+ * - Context Grooming
+ * - Session resume
+ */
+
+import type { AccountRegistry } from './account-registry';
+import type { SafeSendFn } from '../utils/safe-send';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'account-env-injector';
+
+interface SpawnEnv {
+ [key: string]: string | undefined;
+}
+
+/**
+ * Injects CLAUDE_CONFIG_DIR into spawn environment for account multiplexing.
+ * Called by all code paths that spawn Claude Code agents.
+ *
+ * @param sessionId - The session ID being spawned
+ * @param agentType - The agent type (only 'claude-code' is handled)
+ * @param env - Mutable env object to inject into
+ * @param accountRegistry - The account registry instance
+ * @param accountId - Pre-assigned account ID (optional, auto-assigns if missing)
+ * @param safeSend - Optional safeSend function to notify renderer of assignment
+ * @returns The account ID used (or null if no accounts configured)
+ */
+export function injectAccountEnv(
+ sessionId: string,
+ agentType: string,
+ env: SpawnEnv,
+ accountRegistry: AccountRegistry,
+ accountId?: string | null,
+ safeSend?: SafeSendFn,
+): string | null {
+ if (agentType !== 'claude-code') return null;
+
+ // If CLAUDE_CONFIG_DIR is already explicitly set in customEnvVars, respect it
+ if (env.CLAUDE_CONFIG_DIR) {
+ logger.info('CLAUDE_CONFIG_DIR already set, skipping account injection', LOG_CONTEXT, { sessionId });
+ return null;
+ }
+
+ const accounts = accountRegistry.getAll().filter(a => a.status === 'active');
+ if (accounts.length === 0) return null;
+
+ // Use provided accountId, check for existing assignment, or auto-assign
+ let resolvedAccountId = accountId;
+ if (!resolvedAccountId) {
+ // Check for existing assignment (e.g., session resume)
+ const existingAssignment = accountRegistry.getAssignment(sessionId);
+ if (existingAssignment) {
+ const existingAccount = accountRegistry.get(existingAssignment.accountId);
+ if (existingAccount && existingAccount.status === 'active') {
+ resolvedAccountId = existingAssignment.accountId;
+ logger.info(`Reusing existing assignment for session ${sessionId}`, LOG_CONTEXT);
+ }
+ }
+ }
+ if (!resolvedAccountId) {
+ const defaultAccount = accountRegistry.getDefaultAccount();
+ const selected = defaultAccount ?? accountRegistry.selectNextAccount();
+ if (!selected) return null;
+ resolvedAccountId = selected.id;
+ }
+
+ const account = accountRegistry.get(resolvedAccountId);
+ if (!account) return null;
+
+ // Inject the env var
+ env.CLAUDE_CONFIG_DIR = account.configDir;
+
+ // Create/update assignment
+ accountRegistry.assignToSession(sessionId, resolvedAccountId);
+
+ // Notify renderer if safeSend is available
+ if (safeSend) {
+ safeSend('account:assigned', {
+ sessionId,
+ accountId: resolvedAccountId,
+ accountName: account.name,
+ });
+ }
+
+ logger.info(`Assigned account ${account.name} to session ${sessionId}`, LOG_CONTEXT);
+ return resolvedAccountId;
+}
diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts
index 8e42179a4..4cca3bbd5 100644
--- a/src/main/group-chat/group-chat-agent.ts
+++ b/src/main/group-chat/group-chat-agent.ts
@@ -29,6 +29,8 @@ import { groupChatParticipantPrompt } from '../../prompts';
import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper';
import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver';
import { getWindowsSpawnConfig } from './group-chat-config';
+import type { AccountRegistry } from '../accounts/account-registry';
+import { injectAccountEnv } from '../accounts/account-env-injector';
/**
* In-memory store for active participant sessions.
@@ -88,6 +90,8 @@ export interface SessionOverrides {
* @param customEnvVars - Optional custom environment variables for the agent (deprecated, use sessionOverrides)
* @param sessionOverrides - Optional session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteConfig)
* @param sshStore - Optional SSH settings store for remote execution support
+ * @param accountRegistry - Optional account registry for account multiplexing
+ * @param accountId - Optional account ID to use for this participant
* @returns The created participant
*/
export async function addParticipant(
@@ -100,7 +104,9 @@ export async function addParticipant(
agentConfigValues?: Record,
customEnvVars?: Record,
sessionOverrides?: SessionOverrides,
- sshStore?: SshRemoteSettingsStore
+ sshStore?: SshRemoteSettingsStore,
+ accountRegistry?: AccountRegistry,
+ accountId?: string,
): Promise {
console.log(`[GroupChat:Debug] ========== ADD PARTICIPANT ==========`);
console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`);
@@ -184,6 +190,21 @@ export async function addParticipant(
let spawnShell: string | undefined;
let spawnRunInShell = false;
+ // Inject CLAUDE_CONFIG_DIR for account multiplexing
+ if (accountRegistry) {
+ const envToInject: Record = spawnEnvVars ? { ...spawnEnvVars } : {};
+ const assignedId = injectAccountEnv(
+ sessionId,
+ agentId,
+ envToInject,
+ accountRegistry,
+ accountId,
+ );
+ if (assignedId) {
+ spawnEnvVars = envToInject;
+ }
+ }
+
// Apply SSH wrapping if SSH is configured and store is available
if (sshStore && sessionOverrides?.sshRemoteConfig) {
console.log(`[GroupChat:Debug] Applying SSH wrapping for participant...`);
diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts
index 46219c672..0ec8bf645 100644
--- a/src/main/group-chat/group-chat-router.ts
+++ b/src/main/group-chat/group-chat-router.ts
@@ -47,6 +47,8 @@ import {
setGetCustomShellPathCallback,
getWindowsSpawnConfig,
} from './group-chat-config';
+import type { AccountRegistry } from '../accounts/account-registry';
+import { injectAccountEnv } from '../accounts/account-env-injector';
// Import emitters from IPC handlers (will be populated after handlers are registered)
import { groupChatEmitters } from '../ipc/handlers/groupChat';
@@ -98,6 +100,9 @@ let getAgentConfigCallback: GetAgentConfigCallback | null = null;
// Module-level SSH store for remote execution support
let sshStore: SshRemoteSettingsStore | null = null;
+// Module-level account registry for account multiplexing
+let accountRegistryRef: AccountRegistry | null = null;
+
/**
* Tracks pending participant responses for each group chat.
* When all pending participants have responded, we spawn a moderator synthesis round.
@@ -185,6 +190,14 @@ export function setSshStore(store: SshRemoteSettingsStore): void {
sshStore = store;
}
+/**
+ * Sets the account registry for account multiplexing.
+ * Called from index.ts during initialization.
+ */
+export function setAccountRegistry(registry: AccountRegistry): void {
+ accountRegistryRef = registry;
+}
+
/**
* Extracts @mentions from text that match known participants.
* Supports hyphenated names matching participants with spaces.
@@ -336,7 +349,10 @@ export async function routeUserMessage(
sshRemoteConfig: matchingSession.sshRemoteConfig,
},
// Pass SSH store for remote execution support
- sshStore ?? undefined
+ sshStore ?? undefined,
+ // Pass account registry and group-level account ID for multiplexing
+ accountRegistryRef ?? undefined,
+ chat.accountId,
);
existingParticipantNames.add(participantName);
@@ -499,6 +515,21 @@ ${message}`;
let spawnShell: string | undefined;
let spawnRunInShell = false;
+ // Inject CLAUDE_CONFIG_DIR for account multiplexing (moderator)
+ if (accountRegistryRef) {
+ const envToInject: Record = spawnEnvVars ? { ...spawnEnvVars } : {};
+ const assignedId = injectAccountEnv(
+ sessionId,
+ chat.moderatorAgentId,
+ envToInject,
+ accountRegistryRef,
+ chat.accountId,
+ );
+ if (assignedId) {
+ spawnEnvVars = envToInject;
+ }
+ }
+
// Apply SSH wrapping if configured
if (sshStore && chat.moderatorConfig?.sshRemoteConfig) {
console.log(`[GroupChat:Debug] Applying SSH wrapping for moderator...`);
@@ -711,7 +742,10 @@ export async function routeModeratorResponse(
sshRemoteConfig: matchingSession.sshRemoteConfig,
},
// Pass SSH store for remote execution support
- sshStore ?? undefined
+ sshStore ?? undefined,
+ // Pass account registry and group-level account ID for multiplexing
+ accountRegistryRef ?? undefined,
+ chat.accountId,
);
existingParticipantNames.add(participantName);
@@ -875,6 +909,21 @@ export async function routeModeratorResponse(
let finalSpawnShell: string | undefined;
let finalSpawnRunInShell = false;
+ // Inject CLAUDE_CONFIG_DIR for account multiplexing (participant batch spawn)
+ if (accountRegistryRef) {
+ const envToInject: Record = finalSpawnEnvVars ? { ...finalSpawnEnvVars } : {};
+ const assignedId = injectAccountEnv(
+ sessionId,
+ participant.agentId,
+ envToInject,
+ accountRegistryRef,
+ updatedChat.accountId,
+ );
+ if (assignedId) {
+ finalSpawnEnvVars = envToInject;
+ }
+ }
+
// Apply SSH wrapping if configured for this session
if (sshStore && matchingSession?.sshRemoteConfig) {
console.log(
@@ -1215,6 +1264,24 @@ Review the agent responses above. Either:
console.log(`[GroupChat:Debug] Windows shell config for synthesis: ${winConfig.shell}`);
}
+ // Inject CLAUDE_CONFIG_DIR for account multiplexing (synthesis moderator)
+ let synthesisEnvVars =
+ configResolution.effectiveCustomEnvVars ??
+ getCustomEnvVarsCallback?.(chat.moderatorAgentId);
+ if (accountRegistryRef) {
+ const envToInject: Record = synthesisEnvVars ? { ...synthesisEnvVars } : {};
+ const assignedId = injectAccountEnv(
+ sessionId,
+ chat.moderatorAgentId,
+ envToInject,
+ accountRegistryRef,
+ chat.accountId,
+ );
+ if (assignedId) {
+ synthesisEnvVars = envToInject;
+ }
+ }
+
const spawnResult = processManager.spawn({
sessionId,
toolType: chat.moderatorAgentId,
@@ -1224,9 +1291,7 @@ Review the agent responses above. Either:
readOnlyMode: true,
prompt: synthesisPrompt,
contextWindow: getContextWindowValue(agent, agentConfigValues),
- customEnvVars:
- configResolution.effectiveCustomEnvVars ??
- getCustomEnvVarsCallback?.(chat.moderatorAgentId),
+ customEnvVars: synthesisEnvVars,
promptArgs: agent.promptArgs,
noPromptSeparator: agent.noPromptSeparator,
shell: winConfig.shell,
@@ -1375,6 +1440,21 @@ export async function respawnParticipantWithRecovery(
let finalSpawnShell: string | undefined;
let finalSpawnRunInShell = false;
+ // Inject CLAUDE_CONFIG_DIR for account multiplexing (recovery spawn)
+ if (accountRegistryRef) {
+ const envToInject: Record = finalSpawnEnvVars ? { ...finalSpawnEnvVars } : {};
+ const assignedId = injectAccountEnv(
+ sessionId,
+ participant.agentId,
+ envToInject,
+ accountRegistryRef,
+ chat.accountId,
+ );
+ if (assignedId) {
+ finalSpawnEnvVars = envToInject;
+ }
+ }
+
console.log(`[GroupChat:Debug] Recovery spawn command: ${finalSpawnCommand}`);
console.log(`[GroupChat:Debug] Recovery spawn args count: ${finalSpawnArgs.length}`);
diff --git a/src/main/group-chat/group-chat-storage.ts b/src/main/group-chat/group-chat-storage.ts
index 4e913e4e4..f96e5544e 100644
--- a/src/main/group-chat/group-chat-storage.ts
+++ b/src/main/group-chat/group-chat-storage.ts
@@ -125,6 +125,8 @@ export interface GroupChat {
participants: GroupChatParticipant[];
logPath: string;
imagesDir: string;
+ /** Account ID for all participants in this group chat */
+ accountId?: string;
}
/**
diff --git a/src/main/index.ts b/src/main/index.ts
index 6481c48da..8d3e0dedc 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -69,6 +69,7 @@ import {
setGetCustomEnvVarsCallback,
setGetAgentConfigCallback,
setSshStore,
+ setAccountRegistry as setGroupChatAccountRegistry,
setGetCustomShellPathCallback,
markParticipantResponded,
spawnModeratorSynthesis,
@@ -476,6 +477,8 @@ function setupIpcHandlers() {
settingsStore: store,
getMainWindow: () => mainWindow,
sessionsStore,
+ getAccountRegistry: () => accountRegistry,
+ safeSend,
});
// Persistence operations - extracted to src/main/ipc/handlers/persistence.ts
@@ -617,6 +620,11 @@ function setupIpcHandlers() {
// Set up SSH store for group chat SSH remote execution support
setSshStore(createSshRemoteStoreAdapter(store));
+ // Set up account registry for group chat account multiplexing
+ if (accountRegistry) {
+ setGroupChatAccountRegistry(accountRegistry);
+ }
+
// Set up callback for group chat to get custom shell path (for Windows PowerShell preference)
// This is used by both group-chat-router.ts and group-chat-agent.ts via the shared config module
const getCustomShellPathFn = () => store.get('customShellPath', '') as string | undefined;
diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts
index 94ab1362f..1f0c11068 100644
--- a/src/main/ipc/handlers/process.ts
+++ b/src/main/ipc/handlers/process.ts
@@ -4,7 +4,10 @@ import * as os from 'os';
import { ProcessManager } from '../../process-manager';
import { AgentDetector } from '../../agents';
import type { AccountSwitcher } from '../../accounts/account-switcher';
+import type { AccountRegistry } from '../../accounts/account-registry';
+import { injectAccountEnv } from '../../accounts/account-env-injector';
import { logger } from '../../utils/logger';
+import type { SafeSendFn } from '../../utils/safe-send';
import { addBreadcrumb } from '../../utils/sentry';
import { isWebContentsAvailable } from '../../utils/safe-send';
import {
@@ -59,6 +62,8 @@ export interface ProcessHandlerDependencies {
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<{ sessions: any[] }>;
getAccountSwitcher?: () => AccountSwitcher | null;
+ getAccountRegistry?: () => AccountRegistry | null;
+ safeSend?: SafeSendFn;
}
/**
@@ -74,7 +79,7 @@ export interface ProcessHandlerDependencies {
* - runCommand: Execute a single command and capture output
*/
export function registerProcessHandlers(deps: ProcessHandlerDependencies): void {
- const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow, getAccountSwitcher } =
+ const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow, getAccountSwitcher, getAccountRegistry, safeSend: depsSafeSend } =
deps;
// Spawn a new process for a session
@@ -281,6 +286,26 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
let customEnvVarsToPass: Record | undefined = effectiveCustomEnvVars;
let sshStdinScript: string | undefined;
+ // ========================================================================
+ // Account Multiplexing: Inject CLAUDE_CONFIG_DIR for account assignment
+ // Must happen before SSH command building so the env var is included
+ // ========================================================================
+ const registry = getAccountRegistry?.();
+ if (registry) {
+ const envToInject: Record = customEnvVarsToPass ? { ...customEnvVarsToPass } : {};
+ const assignedAccountId = injectAccountEnv(
+ config.sessionId,
+ config.toolType,
+ envToInject,
+ registry,
+ (config as any).accountId, // May be passed from renderer
+ depsSafeSend,
+ );
+ if (assignedAccountId) {
+ customEnvVarsToPass = envToInject;
+ }
+ }
+
if (config.sessionCustomPath) {
logger.debug(`Using session-level custom path for ${config.toolType}`, LOG_CONTEXT, {
customPath: config.sessionCustomPath,
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index e42e7bba6..aa5c92ba8 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -233,6 +233,18 @@ export function createAccountsApi() {
return () => ipcRenderer.removeListener('account:status-changed', wrappedHandler);
},
+ /**
+ * Subscribe to account assignment events (when a session is assigned an account during spawn)
+ * @param handler - Callback with assignment data
+ * @returns Cleanup function to unsubscribe
+ */
+ onAssigned: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: { sessionId: string; accountId: string; accountName: string }) =>
+ handler(data);
+ ipcRenderer.on('account:assigned', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:assigned', wrappedHandler);
+ },
+
// --- Session Cleanup ---
/** Clean up account data when a session is closed */
diff --git a/src/main/utils/context-groomer.ts b/src/main/utils/context-groomer.ts
index e5bc6d06d..1cd270b60 100644
--- a/src/main/utils/context-groomer.ts
+++ b/src/main/utils/context-groomer.ts
@@ -16,6 +16,8 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from './logger';
import { buildAgentArgs } from './agent-args';
import type { AgentDetector } from '../agents';
+import type { AccountRegistry } from '../accounts/account-registry';
+import { injectAccountEnv } from '../accounts/account-env-injector';
const LOG_CONTEXT = '[ContextGroomer]';
@@ -129,6 +131,10 @@ export interface GroomContextOptions {
sessionCustomArgs?: string;
/** Custom environment variables for the agent */
sessionCustomEnvVars?: Record;
+ /** Account registry for multiplexing (optional) */
+ accountRegistry?: AccountRegistry;
+ /** Account ID to inherit from parent session (optional) */
+ accountId?: string;
}
/**
@@ -170,6 +176,8 @@ export async function groomContext(
sessionCustomPath,
sessionCustomArgs,
sessionCustomEnvVars,
+ accountRegistry: optAccountRegistry,
+ accountId: optAccountId,
} = options;
const groomerSessionId = `groomer-${uuidv4()}`;
@@ -317,6 +325,22 @@ export async function groomContext(
processManager.on('exit', onExit);
processManager.on('agent-error', onError);
+ // Inject CLAUDE_CONFIG_DIR for account multiplexing (grooming inherits parent account)
+ let effectiveEnvVars = sessionCustomEnvVars;
+ if (optAccountRegistry) {
+ const envToInject: Record = effectiveEnvVars ? { ...effectiveEnvVars } : {};
+ const assignedId = injectAccountEnv(
+ groomerSessionId,
+ agentType,
+ envToInject,
+ optAccountRegistry,
+ optAccountId,
+ );
+ if (assignedId) {
+ effectiveEnvVars = envToInject;
+ }
+ }
+
// Spawn the process in batch mode
const spawnResult = processManager.spawn({
sessionId: groomerSessionId,
@@ -331,7 +355,7 @@ export async function groomContext(
sessionSshRemoteConfig,
sessionCustomPath,
sessionCustomArgs,
- sessionCustomEnvVars,
+ sessionCustomEnvVars: effectiveEnvVars,
});
if (!spawnResult || spawnResult.pid <= 0) {
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index b702f694a..7be752e7a 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -2168,6 +2168,19 @@ function MaestroConsoleInner() {
};
}, []);
+ // Subscribe to account assignment events (update session state when main process assigns an account)
+ useEffect(() => {
+ const unsubAssigned = window.maestro.accounts.onAssigned((data) => {
+ setSessions((prev) =>
+ prev.map(s => {
+ if (!data.sessionId.startsWith(s.id)) return s;
+ return { ...s, accountId: data.accountId, accountName: data.accountName };
+ })
+ );
+ });
+ return () => unsubAssigned();
+ }, []);
+
// Subscribe to account switch events (respawn agent with new account after switch)
useEffect(() => {
const unsubRespawn = window.maestro.accounts.onSwitchRespawn(async (data) => {
@@ -8112,6 +8125,20 @@ You are taking over this conversation. Based on the context above, provide a bri
// Per-session SSH remote config (takes precedence over agent-level SSH config)
sessionSshRemoteConfig,
};
+
+ // Pre-assign account for Claude Code sessions if accounts are configured
+ if (newSession.toolType === 'claude-code') {
+ try {
+ const defaultAccount = await window.maestro.accounts.getDefault() as { id: string; name: string } | null;
+ if (defaultAccount) {
+ newSession.accountId = defaultAccount.id;
+ newSession.accountName = defaultAccount.name;
+ }
+ } catch {
+ // Accounts not configured or unavailable — proceed without assignment
+ }
+ }
+
setSessions((prev) => [...prev, newSession]);
setActiveSessionId(newId);
// Track session creation in global stats
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 4ced8c690..b76d0a578 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2641,6 +2641,7 @@ interface MaestroAPI {
onSwitchPrompt: (handler: (data: Record) => void) => () => void;
onSwitchExecute: (handler: (data: Record) => void) => () => void;
onStatusChanged: (handler: (data: Record) => void) => () => void;
+ onAssigned: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
cleanupSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
executeSwitch: (params: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean }) => Promise<{ success: boolean; event?: unknown; error?: string }>;
onSwitchStarted: (handler: (data: Record) => void) => () => void;
From 1143390c9f857d56cfa7b0f0cdf400a29d977d92 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 11:12:04 -0500
Subject: [PATCH 12/59] MAESTRO: feat: add session/assignment persistence and
restart recovery for account multiplexing
Ensures account assignments survive app restarts by reconciling accounts on
session restore. Adds typed accountId to spawn config, reconcileAssignments()
to AccountRegistry, and an accounts:reconcile-sessions IPC handler called
after session restore to validate/correct account state and CLAUDE_CONFIG_DIR.
Co-Authored-By: Claude Opus 4.6
---
.../main/accounts/account-registry.test.ts | 44 +++++++++++++
src/main/accounts/account-registry.ts | 25 ++++++++
src/main/ipc/handlers/accounts.ts | 62 +++++++++++++++++++
src/main/ipc/handlers/process.ts | 4 +-
src/main/preload/accounts.ts | 17 +++++
src/renderer/App.tsx | 40 ++++++++++++
src/renderer/global.d.ts | 3 +
.../hooks/input/useInputProcessing.ts | 2 +
8 files changed, 196 insertions(+), 1 deletion(-)
diff --git a/src/__tests__/main/accounts/account-registry.test.ts b/src/__tests__/main/accounts/account-registry.test.ts
index b259470a7..df77f4897 100644
--- a/src/__tests__/main/accounts/account-registry.test.ts
+++ b/src/__tests__/main/accounts/account-registry.test.ts
@@ -343,6 +343,50 @@ describe('AccountRegistry', () => {
});
});
+ describe('reconcileAssignments', () => {
+ it('should remove assignments for sessions not in the active set', () => {
+ const account = registry.add(makeParams());
+ registry.assignToSession('session-1', account.id);
+ registry.assignToSession('session-2', account.id);
+ registry.assignToSession('session-3', account.id);
+
+ // Only session-1 and session-3 are still active
+ const removed = registry.reconcileAssignments(new Set(['session-1', 'session-3']));
+
+ expect(removed).toBe(1);
+ expect(registry.getAssignment('session-1')).not.toBeNull();
+ expect(registry.getAssignment('session-2')).toBeNull();
+ expect(registry.getAssignment('session-3')).not.toBeNull();
+ });
+
+ it('should return 0 when all assignments are valid', () => {
+ const account = registry.add(makeParams());
+ registry.assignToSession('session-1', account.id);
+
+ const removed = registry.reconcileAssignments(new Set(['session-1']));
+
+ expect(removed).toBe(0);
+ expect(registry.getAssignment('session-1')).not.toBeNull();
+ });
+
+ it('should handle empty assignment map', () => {
+ const removed = registry.reconcileAssignments(new Set(['session-1']));
+
+ expect(removed).toBe(0);
+ });
+
+ it('should remove all assignments when no sessions are active', () => {
+ const account = registry.add(makeParams());
+ registry.assignToSession('session-1', account.id);
+ registry.assignToSession('session-2', account.id);
+
+ const removed = registry.reconcileAssignments(new Set());
+
+ expect(removed).toBe(2);
+ expect(registry.getAllAssignments()).toHaveLength(0);
+ });
+ });
+
describe('switchConfig', () => {
it('should return defaults initially', () => {
const config = registry.getSwitchConfig();
diff --git a/src/main/accounts/account-registry.ts b/src/main/accounts/account-registry.ts
index 52887484b..98d0bc077 100644
--- a/src/main/accounts/account-registry.ts
+++ b/src/main/accounts/account-registry.ts
@@ -9,6 +9,9 @@ import type {
} from '../../shared/account-types';
import { DEFAULT_TOKEN_WINDOW_MS, ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
import { generateUUID } from '../../shared/uuid';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'AccountRegistry';
export class AccountRegistry {
constructor(private store: Store) {}
@@ -208,6 +211,28 @@ export class AccountRegistry {
return available[0];
}
+ // --- Reconciliation ---
+
+ /**
+ * Reconcile assignments with a list of active session IDs.
+ * Removes assignments for sessions that no longer exist.
+ * Called on app startup after session restore.
+ */
+ reconcileAssignments(activeSessionIds: Set): number {
+ const assignments = this.getAllAssignments();
+ let removed = 0;
+ for (const assignment of assignments) {
+ if (!activeSessionIds.has(assignment.sessionId)) {
+ this.removeAssignment(assignment.sessionId);
+ removed++;
+ }
+ }
+ if (removed > 0) {
+ logger.info(`Reconciled ${removed} stale account assignments`, LOG_CONTEXT);
+ }
+ return removed;
+ }
+
// --- Switch Config ---
getSwitchConfig(): AccountSwitchConfig {
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index 38d013b38..7140ac7b3 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -353,6 +353,68 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
}
});
+ // --- Startup Reconciliation ---
+
+ ipcMain.handle('accounts:reconcile-sessions', async (_event, activeSessionIds: string[]) => {
+ try {
+ const registry = requireRegistry();
+ const idSet = new Set(activeSessionIds);
+
+ // Remove stale assignments for sessions that no longer exist
+ const removed = registry.reconcileAssignments(idSet);
+
+ // For each active session with an assignment, validate the account still exists
+ // Return corrections for sessions whose accounts were removed
+ const corrections: Array<{
+ sessionId: string;
+ accountId: string | null;
+ accountName: string | null;
+ configDir: string | null;
+ status: 'valid' | 'removed' | 'inactive';
+ }> = [];
+
+ for (const sessionId of activeSessionIds) {
+ const assignment = registry.getAssignment(sessionId);
+ if (!assignment) continue;
+
+ const account = registry.get(assignment.accountId);
+ if (!account) {
+ // Account was removed — clear the assignment
+ registry.removeAssignment(sessionId);
+ corrections.push({
+ sessionId,
+ accountId: null,
+ accountName: null,
+ configDir: null,
+ status: 'removed',
+ });
+ } else if (account.status !== 'active') {
+ // Account exists but is throttled/disabled — still usable but warn
+ corrections.push({
+ sessionId,
+ accountId: account.id,
+ accountName: account.name,
+ configDir: account.configDir,
+ status: 'inactive',
+ });
+ } else {
+ corrections.push({
+ sessionId,
+ accountId: account.id,
+ accountName: account.name,
+ configDir: account.configDir,
+ status: 'valid',
+ });
+ }
+ }
+
+ return { success: true, removed, corrections };
+ } catch (error) {
+ logger.error('reconcile sessions error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, removed: 0, corrections: [], error: String(error) };
+ }
+ });
+
// --- Account Switching ---
ipcMain.handle('accounts:execute-switch', async (_event, params: {
diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts
index 1f0c11068..bbdb30351 100644
--- a/src/main/ipc/handlers/process.ts
+++ b/src/main/ipc/handlers/process.ts
@@ -117,6 +117,8 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
remoteId: string | null;
workingDirOverride?: string;
};
+ // Account multiplexing
+ accountId?: string; // Account to use for this session
// Stats tracking options
querySource?: 'user' | 'auto'; // Whether this query is user-initiated or from Auto Run
tabId?: string; // Tab ID for multi-tab tracking
@@ -298,7 +300,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
config.toolType,
envToInject,
registry,
- (config as any).accountId, // May be passed from renderer
+ config.accountId, // May be passed from renderer
depsSafeSend,
);
if (assignedAccountId) {
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index aa5c92ba8..135a96c5b 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -245,6 +245,23 @@ export function createAccountsApi() {
return () => ipcRenderer.removeListener('account:assigned', wrappedHandler);
},
+ // --- Session Reconciliation ---
+
+ /** Reconcile account assignments after session restore on startup.
+ * Removes stale assignments and returns account validation for sessions with accountId. */
+ reconcileSessions: (activeSessionIds: string[]): Promise<{
+ success: boolean;
+ removed: number;
+ corrections: Array<{
+ sessionId: string;
+ accountId: string | null;
+ accountName: string | null;
+ configDir: string | null;
+ status: 'valid' | 'removed' | 'inactive';
+ }>;
+ error?: string;
+ }> => ipcRenderer.invoke('accounts:reconcile-sessions', activeSessionIds),
+
// --- Session Cleanup ---
/** Clean up account data when a session is closed */
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 7be752e7a..c1de4c35c 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1402,6 +1402,45 @@ function MaestroConsoleInner() {
setActiveSessionId(restoredSessions[0].id);
}
+ // Reconcile account assignments after session restore (ACCT-MUX-13)
+ // This validates accounts still exist and updates customEnvVars accordingly
+ try {
+ const activeIds = restoredSessions.map(s => s.id);
+ const reconciliation = await window.maestro.accounts.reconcileSessions(activeIds);
+ if (reconciliation.success && reconciliation.corrections.length > 0) {
+ setSessions(prev => prev.map(session => {
+ const correction = reconciliation.corrections.find(c => c.sessionId === session.id);
+ if (!correction) return session;
+
+ if (correction.status === 'removed') {
+ // Account was removed — clear session's account fields and CLAUDE_CONFIG_DIR
+ const cleanedEnvVars = { ...session.customEnvVars };
+ delete cleanedEnvVars.CLAUDE_CONFIG_DIR;
+ return {
+ ...session,
+ accountId: undefined,
+ accountName: undefined,
+ customEnvVars: Object.keys(cleanedEnvVars).length > 0 ? cleanedEnvVars : undefined,
+ };
+ } else if (correction.configDir && session.accountId) {
+ // Account exists — ensure CLAUDE_CONFIG_DIR is current
+ return {
+ ...session,
+ accountId: correction.accountId ?? undefined,
+ accountName: correction.accountName ?? undefined,
+ customEnvVars: {
+ ...session.customEnvVars,
+ CLAUDE_CONFIG_DIR: correction.configDir,
+ },
+ };
+ }
+ return session;
+ }));
+ }
+ } catch (reconcileError) {
+ console.error('[App] Account reconciliation failed:', reconcileError);
+ }
+
// For remote (SSH) sessions, fetch git info in background to avoid blocking
// startup on SSH connection timeouts. This runs after UI is shown.
for (const session of restoredSessions) {
@@ -2242,6 +2281,7 @@ function MaestroConsoleInner() {
},
sessionCustomModel: session.customModel,
sessionCustomContextWindow: session.customContextWindow,
+ accountId: toAccountId,
sessionSshRemoteConfig: session.sessionSshRemoteConfig,
});
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index b76d0a578..8cfa8115c 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -36,6 +36,8 @@ interface ProcessConfig {
sessionCustomEnvVars?: Record;
sessionCustomModel?: string;
sessionCustomContextWindow?: number;
+ // Account multiplexing
+ accountId?: string;
// Per-session SSH remote config (takes precedence over agent-level SSH config)
sessionSshRemoteConfig?: {
enabled: boolean;
@@ -2642,6 +2644,7 @@ interface MaestroAPI {
onSwitchExecute: (handler: (data: Record) => void) => () => void;
onStatusChanged: (handler: (data: Record) => void) => () => void;
onAssigned: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
+ reconcileSessions: (activeSessionIds: string[]) => Promise<{ success: boolean; removed: number; corrections: Array<{ sessionId: string; accountId: string | null; accountName: string | null; configDir: string | null; status: 'valid' | 'removed' | 'inactive' }>; error?: string }>;
cleanupSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
executeSwitch: (params: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean }) => Promise<{ success: boolean; event?: unknown; error?: string }>;
onSwitchStarted: (handler: (data: Record) => void) => () => void;
diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts
index d9d1da7b9..a8dd98ade 100644
--- a/src/renderer/hooks/input/useInputProcessing.ts
+++ b/src/renderer/hooks/input/useInputProcessing.ts
@@ -947,6 +947,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
sessionCustomEnvVars: freshSession.customEnvVars,
sessionCustomModel: freshSession.customModel,
sessionCustomContextWindow: freshSession.customContextWindow,
+ // Account multiplexing - pass accountId so spawn uses the correct account
+ accountId: freshSession.accountId,
// Per-session SSH remote config (takes precedence over agent-level SSH config)
sessionSshRemoteConfig: freshSession.sessionSshRemoteConfig,
// Windows stdin handling - send prompt via stdin to avoid shell escaping issues
From 941ad8584d781d5617ca977fe65b2009188d7de7 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 11:22:57 -0500
Subject: [PATCH 13/59] MAESTRO: feat: add account multiplexing support to CLI
batch runner
Adds CLI-side account reading, --account and --account-rotation flags
for the playbook command, CLAUDE_CONFIG_DIR injection in agent-spawner,
round-robin account rotation in batch-processor, and a new 'accounts'
command to list configured accounts from the terminal.
Co-Authored-By: Claude Opus 4.6
---
.../cli/services/account-reader.test.ts | 251 ++++++++++++++++++
src/cli/commands/accounts.ts | 30 +++
src/cli/commands/run-playbook.ts | 4 +
src/cli/index.ts | 11 +
src/cli/services/account-reader.ts | 156 +++++++++++
src/cli/services/agent-spawner.ts | 13 +-
src/cli/services/batch-processor.ts | 62 ++++-
7 files changed, 520 insertions(+), 7 deletions(-)
create mode 100644 src/__tests__/cli/services/account-reader.test.ts
create mode 100644 src/cli/commands/accounts.ts
create mode 100644 src/cli/services/account-reader.ts
diff --git a/src/__tests__/cli/services/account-reader.test.ts b/src/__tests__/cli/services/account-reader.test.ts
new file mode 100644
index 000000000..5bf6b6dac
--- /dev/null
+++ b/src/__tests__/cli/services/account-reader.test.ts
@@ -0,0 +1,251 @@
+/**
+ * @file account-reader.test.ts
+ * @description Tests for the CLI account reader service
+ *
+ * Tests reading account data from the electron-store JSON file,
+ * filesystem discovery fallback, and account lookup helpers.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import type { AccountProfile } from '../../../shared/account-types';
+
+// Mock the fs module
+vi.mock('fs', () => ({
+ readFileSync: vi.fn(),
+ promises: {
+ readdir: vi.fn(),
+ readFile: vi.fn(),
+ stat: vi.fn(),
+ },
+}));
+
+// Mock the os module
+vi.mock('os', () => ({
+ platform: vi.fn(),
+ homedir: vi.fn(),
+}));
+
+import {
+ readAccountsFromStore,
+ getDefaultAccount,
+ getAccountByIdOrName,
+} from '../../../cli/services/account-reader';
+
+// Helper to build a mock AccountProfile
+function mockProfile(overrides: Partial = {}): AccountProfile {
+ return {
+ id: 'acc-1',
+ name: 'personal',
+ email: 'user@example.com',
+ configDir: '/home/testuser/.claude-personal',
+ agentType: 'claude-code',
+ status: 'active',
+ authMethod: 'oauth',
+ addedAt: 1000,
+ lastUsedAt: 2000,
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: 18000000,
+ isDefault: true,
+ autoSwitchEnabled: false,
+ ...overrides,
+ };
+}
+
+describe('account-reader', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(os.platform).mockReturnValue('linux');
+ vi.mocked(os.homedir).mockReturnValue('/home/testuser');
+ });
+
+ describe('readAccountsFromStore', () => {
+ it('reads accounts from the store JSON file', async () => {
+ const profile1 = mockProfile({ id: 'acc-1', name: 'personal', isDefault: true });
+ const profile2 = mockProfile({
+ id: 'acc-2',
+ name: 'work',
+ email: 'work@corp.com',
+ configDir: '/home/testuser/.claude-work',
+ isDefault: false,
+ });
+
+ const storeData = {
+ accounts: {
+ 'acc-1': profile1,
+ 'acc-2': profile2,
+ },
+ assignments: {},
+ switchConfig: {},
+ rotationOrder: [],
+ rotationIndex: 0,
+ };
+
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const accounts = await readAccountsFromStore();
+
+ expect(accounts).toHaveLength(2);
+ expect(accounts.find((a) => a.id === 'acc-1')).toMatchObject({
+ id: 'acc-1',
+ name: 'personal',
+ email: 'user@example.com',
+ isDefault: true,
+ status: 'active',
+ });
+ expect(accounts.find((a) => a.id === 'acc-2')).toMatchObject({
+ id: 'acc-2',
+ name: 'work',
+ email: 'work@corp.com',
+ isDefault: false,
+ });
+ });
+
+ it('returns empty array when store has no accounts', async () => {
+ vi.mocked(fs.readFileSync).mockReturnValue(
+ JSON.stringify({ accounts: {}, assignments: {} })
+ );
+
+ const accounts = await readAccountsFromStore();
+ expect(accounts).toHaveLength(0);
+ });
+
+ it('falls back to filesystem discovery when store file missing', async () => {
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
+ throw new Error('ENOENT');
+ });
+
+ vi.mocked(fs.promises.readdir).mockResolvedValue([
+ { name: '.claude-personal', isDirectory: () => true } as unknown as fs.Dirent,
+ { name: '.bashrc', isDirectory: () => false } as unknown as fs.Dirent,
+ { name: 'Documents', isDirectory: () => true } as unknown as fs.Dirent,
+ ]);
+
+ vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('ENOENT'));
+
+ const accounts = await readAccountsFromStore();
+ expect(accounts).toHaveLength(1);
+ expect(accounts[0]).toMatchObject({
+ id: 'personal',
+ name: 'personal',
+ configDir: '/home/testuser/.claude-personal',
+ status: 'active',
+ });
+ });
+
+ it('reads email from .claude.json during filesystem discovery', async () => {
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
+ throw new Error('ENOENT');
+ });
+
+ vi.mocked(fs.promises.readdir).mockResolvedValue([
+ { name: '.claude-work', isDirectory: () => true } as unknown as fs.Dirent,
+ ]);
+
+ vi.mocked(fs.promises.readFile).mockResolvedValue(
+ JSON.stringify({ email: 'dev@company.com' })
+ );
+
+ const accounts = await readAccountsFromStore();
+ expect(accounts[0].email).toBe('dev@company.com');
+ });
+
+ it('handles macOS store path', async () => {
+ vi.mocked(os.platform).mockReturnValue('darwin');
+
+ const storeData = {
+ accounts: { 'acc-1': mockProfile() },
+ assignments: {},
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const accounts = await readAccountsFromStore();
+ expect(accounts).toHaveLength(1);
+
+ // Should try macOS path first
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ '/home/testuser/Library/Application Support/Maestro/maestro-accounts.json',
+ 'utf-8'
+ );
+ });
+ });
+
+ describe('getDefaultAccount', () => {
+ it('returns the default active account', async () => {
+ const storeData = {
+ accounts: {
+ 'acc-1': mockProfile({ id: 'acc-1', isDefault: false }),
+ 'acc-2': mockProfile({ id: 'acc-2', isDefault: true }),
+ },
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const account = await getDefaultAccount();
+ expect(account?.id).toBe('acc-2');
+ });
+
+ it('returns first active account when no default set', async () => {
+ const storeData = {
+ accounts: {
+ 'acc-1': mockProfile({ id: 'acc-1', isDefault: false }),
+ },
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const account = await getDefaultAccount();
+ expect(account?.id).toBe('acc-1');
+ });
+
+ it('skips throttled accounts when looking for default', async () => {
+ const storeData = {
+ accounts: {
+ 'acc-1': mockProfile({ id: 'acc-1', isDefault: true, status: 'throttled' }),
+ 'acc-2': mockProfile({ id: 'acc-2', isDefault: false, status: 'active' }),
+ },
+ };
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const account = await getDefaultAccount();
+ expect(account?.id).toBe('acc-2');
+ });
+
+ it('returns null when no accounts exist', async () => {
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ accounts: {} }));
+
+ const account = await getDefaultAccount();
+ expect(account).toBeNull();
+ });
+ });
+
+ describe('getAccountByIdOrName', () => {
+ const storeData = {
+ accounts: {
+ 'acc-1': mockProfile({ id: 'acc-1', name: 'personal' }),
+ 'acc-2': mockProfile({ id: 'acc-2', name: 'work' }),
+ },
+ };
+
+ it('finds by ID', async () => {
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const account = await getAccountByIdOrName('acc-2');
+ expect(account?.name).toBe('work');
+ });
+
+ it('finds by name', async () => {
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const account = await getAccountByIdOrName('personal');
+ expect(account?.id).toBe('acc-1');
+ });
+
+ it('returns null when not found', async () => {
+ vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(storeData));
+
+ const account = await getAccountByIdOrName('nonexistent');
+ expect(account).toBeNull();
+ });
+ });
+});
diff --git a/src/cli/commands/accounts.ts b/src/cli/commands/accounts.ts
new file mode 100644
index 000000000..b64b352d6
--- /dev/null
+++ b/src/cli/commands/accounts.ts
@@ -0,0 +1,30 @@
+// Accounts command - list configured Claude accounts
+
+import { readAccountsFromStore } from '../services/account-reader';
+
+export async function listAccounts(): Promise {
+ const accounts = await readAccountsFromStore();
+
+ if (accounts.length === 0) {
+ console.log('No accounts configured. Use Maestro Settings > Accounts to add accounts.');
+ return;
+ }
+
+ console.log('\nConfigured Accounts:');
+ console.log('\u2500'.repeat(60));
+
+ for (const account of accounts) {
+ const defaultBadge = account.isDefault ? ' [DEFAULT]' : '';
+ const statusIcon =
+ account.status === 'active'
+ ? '\u2713'
+ : account.status === 'throttled'
+ ? '\u26A0'
+ : '\u2717';
+ console.log(` ${statusIcon} ${account.name}${defaultBadge}`);
+ console.log(` Email: ${account.email || 'unknown'}`);
+ console.log(` Dir: ${account.configDir}`);
+ console.log(` Status: ${account.status}`);
+ console.log('');
+ }
+}
diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts
index 34a26bd95..b4e1d157d 100644
--- a/src/cli/commands/run-playbook.ts
+++ b/src/cli/commands/run-playbook.ts
@@ -69,6 +69,8 @@ interface RunPlaybookOptions {
debug?: boolean;
verbose?: boolean;
wait?: boolean;
+ account?: string;
+ accountRotation?: boolean;
}
/**
@@ -265,6 +267,8 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption
writeHistory: options.history !== false, // --no-history sets history to false
debug: options.debug,
verbose: options.verbose,
+ account: options.account,
+ accountRotation: options.accountRotation,
});
for await (const event of generator) {
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 95c99bdce..82bb61f20 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -13,6 +13,7 @@ import { showAgent } from './commands/show-agent';
import { cleanPlaybooks } from './commands/clean-playbooks';
import { send } from './commands/send';
import { listSessions } from './commands/list-sessions';
+import { listAccounts } from './commands/accounts';
// Read version from package.json at runtime
function getVersion(): string {
@@ -87,6 +88,8 @@ program
.option('--debug', 'Show detailed debug output for troubleshooting')
.option('--verbose', 'Show full prompt sent to agent on each iteration')
.option('--wait', 'Wait for agent to become available if busy')
+ .option('--account ', 'Claude account name or ID to use for all spawned agents')
+ .option('--account-rotation', 'Rotate through available accounts for parallel tasks')
.action(async (playbookId: string, options: Record) => {
const { runPlaybook } = await import('./commands/run-playbook');
return runPlaybook(playbookId, options);
@@ -109,4 +112,12 @@ program
.option('-s, --session ', 'Resume an existing agent session (for multi-turn conversations)')
.action(send);
+// Accounts command
+program
+ .command('accounts')
+ .description('List configured Claude accounts')
+ .action(async () => {
+ await listAccounts();
+ });
+
program.parse();
diff --git a/src/cli/services/account-reader.ts b/src/cli/services/account-reader.ts
new file mode 100644
index 000000000..32592b7cc
--- /dev/null
+++ b/src/cli/services/account-reader.ts
@@ -0,0 +1,156 @@
+// CLI-compatible account reader
+// Reads account data directly from the filesystem since CLI runs outside Electron
+
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import type { AccountProfile, AccountStatus } from '../../shared/account-types';
+import type { AccountStoreData } from '../../main/stores/account-store-types';
+
+export interface CLIAccountInfo {
+ id: string;
+ name: string;
+ email: string;
+ configDir: string;
+ status: AccountStatus;
+ isDefault: boolean;
+}
+
+/**
+ * Get possible paths for the Maestro accounts store file.
+ * electron-store may use either capitalized or lowercase directory name
+ * depending on platform and configuration.
+ */
+function getAccountStorePaths(): string[] {
+ const platform = os.platform();
+ const home = os.homedir();
+ const paths: string[] = [];
+
+ if (platform === 'darwin') {
+ paths.push(path.join(home, 'Library', 'Application Support', 'Maestro', 'maestro-accounts.json'));
+ paths.push(path.join(home, 'Library', 'Application Support', 'maestro', 'maestro-accounts.json'));
+ } else if (platform === 'win32') {
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
+ paths.push(path.join(appData, 'Maestro', 'maestro-accounts.json'));
+ paths.push(path.join(appData, 'maestro', 'maestro-accounts.json'));
+ } else {
+ const configBase = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
+ paths.push(path.join(configBase, 'Maestro', 'maestro-accounts.json'));
+ paths.push(path.join(configBase, 'maestro', 'maestro-accounts.json'));
+ }
+
+ return paths;
+}
+
+/**
+ * Convert an AccountProfile from the store into a CLIAccountInfo.
+ */
+function profileToCliInfo(profile: AccountProfile): CLIAccountInfo {
+ return {
+ id: profile.id,
+ name: profile.name,
+ email: profile.email || '',
+ configDir: profile.configDir,
+ status: profile.status || 'active',
+ isDefault: profile.isDefault || false,
+ };
+}
+
+/**
+ * Read account profiles from the Maestro electron-store JSON file.
+ * Falls back to filesystem discovery if the store file doesn't exist.
+ */
+export async function readAccountsFromStore(): Promise {
+ const storePaths = getAccountStorePaths();
+
+ for (const storePath of storePaths) {
+ try {
+ const content = fs.readFileSync(storePath, 'utf-8');
+ const store: AccountStoreData = JSON.parse(content);
+ const accounts: CLIAccountInfo[] = [];
+
+ if (store.accounts && typeof store.accounts === 'object') {
+ for (const profile of Object.values(store.accounts)) {
+ accounts.push(profileToCliInfo(profile));
+ }
+ }
+
+ return accounts;
+ } catch {
+ // Try next path
+ continue;
+ }
+ }
+
+ // Store file doesn't exist at any path — try filesystem discovery
+ return discoverAccountsFromFilesystem();
+}
+
+/**
+ * Discover accounts by scanning for ~/.claude-* directories.
+ * Fallback when electron-store is not available.
+ */
+async function discoverAccountsFromFilesystem(): Promise {
+ const homeDir = os.homedir();
+
+ let entries: fs.Dirent[];
+ try {
+ entries = await fs.promises.readdir(homeDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+
+ const accounts: CLIAccountInfo[] = [];
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ if (!entry.name.startsWith('.claude-')) continue;
+
+ const configDir = path.join(homeDir, entry.name);
+ const name = entry.name.replace('.claude-', '');
+
+ // Check for auth info
+ let email = '';
+ try {
+ const authContent = await fs.promises.readFile(
+ path.join(configDir, '.claude.json'),
+ 'utf-8'
+ );
+ const json = JSON.parse(authContent);
+ email = json.email || json.accountEmail || json.primaryEmail || '';
+ } catch {
+ // no auth file
+ }
+
+ accounts.push({
+ id: name,
+ name,
+ email,
+ configDir,
+ status: 'active',
+ isDefault: false,
+ });
+ }
+
+ return accounts;
+}
+
+/**
+ * Get the default account, or the first active account, or null.
+ */
+export async function getDefaultAccount(): Promise {
+ const accounts = await readAccountsFromStore();
+ return (
+ accounts.find((a) => a.isDefault && a.status === 'active') ||
+ accounts.find((a) => a.status === 'active') ||
+ null
+ );
+}
+
+/**
+ * Get a specific account by ID or name.
+ */
+export async function getAccountByIdOrName(idOrName: string): Promise {
+ const accounts = await readAccountsFromStore();
+ return accounts.find((a) => a.id === idOrName || a.name === idOrName) || null;
+}
diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts
index c07555acd..aa113d849 100644
--- a/src/cli/services/agent-spawner.ts
+++ b/src/cli/services/agent-spawner.ts
@@ -223,11 +223,17 @@ export function getCodexCommand(): string {
async function spawnClaudeAgent(
cwd: string,
prompt: string,
- agentSessionId?: string
+ agentSessionId?: string,
+ configDir?: string
): Promise {
return new Promise((resolve) => {
const env = buildExpandedEnv();
+ // Inject account config dir if provided (account multiplexing)
+ if (configDir) {
+ env.CLAUDE_CONFIG_DIR = configDir;
+ }
+
// Build args: base args + session handling + prompt
const args = [...CLAUDE_ARGS];
@@ -473,14 +479,15 @@ export async function spawnAgent(
toolType: ToolType,
cwd: string,
prompt: string,
- agentSessionId?: string
+ agentSessionId?: string,
+ configDir?: string
): Promise {
if (toolType === 'codex') {
return spawnCodexAgent(cwd, prompt, agentSessionId);
}
if (toolType === 'claude-code') {
- return spawnClaudeAgent(cwd, prompt, agentSessionId);
+ return spawnClaudeAgent(cwd, prompt, agentSessionId, configDir);
}
return {
diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts
index 3127cac6a..0a7f52f90 100644
--- a/src/cli/services/batch-processor.ts
+++ b/src/cli/services/batch-processor.ts
@@ -11,6 +11,8 @@ import {
uncheckAllTasks,
writeDoc,
} from './agent-spawner';
+import { readAccountsFromStore, getAccountByIdOrName, getDefaultAccount } from './account-reader';
+import type { CLIAccountInfo } from './account-reader';
import { addHistoryEntry, readGroups } from './storage';
import { substituteTemplateVariables, TemplateContext } from '../../shared/templateVariables';
import { registerCliActivity, unregisterCliActivity } from '../../shared/cli-activity';
@@ -55,6 +57,35 @@ function isGitRepo(cwd: string): boolean {
}
}
+/**
+ * Resolve the account configDir for a given task, based on CLI options.
+ * - If --account is set, use that specific account.
+ * - If --account-rotation is set, round-robin through active accounts.
+ * - Otherwise, use the default account if one exists.
+ */
+async function resolveAccountConfigDir(
+ taskIndex: number,
+ accountOption?: string,
+ accountRotation?: boolean,
+ cachedAccounts?: CLIAccountInfo[] | null,
+): Promise {
+ if (accountOption) {
+ const account = await getAccountByIdOrName(accountOption);
+ return account?.configDir;
+ }
+
+ if (accountRotation) {
+ const accounts = cachedAccounts ?? await readAccountsFromStore();
+ const activeAccounts = accounts.filter((a) => a.status === 'active');
+ if (activeAccounts.length === 0) return undefined;
+ const account = activeAccounts[taskIndex % activeAccounts.length];
+ return account.configDir;
+ }
+
+ const defaultAccount = await getDefaultAccount();
+ return defaultAccount?.configDir;
+}
+
/**
* Process a playbook and yield JSONL events
*/
@@ -67,9 +98,18 @@ export async function* runPlaybook(
writeHistory?: boolean;
debug?: boolean;
verbose?: boolean;
+ account?: string;
+ accountRotation?: boolean;
} = {}
): AsyncGenerator {
- const { dryRun = false, writeHistory = true, debug = false, verbose = false } = options;
+ const {
+ dryRun = false,
+ writeHistory = true,
+ debug = false,
+ verbose = false,
+ account: accountOption,
+ accountRotation = false,
+ } = options;
const batchStartTime = Date.now();
// Get git branch and group name for template variable substitution
@@ -79,6 +119,9 @@ export async function* runPlaybook(
const sessionGroup = groups.find((g) => g.id === session.groupId);
const groupName = sessionGroup?.name;
+ // Pre-cache accounts for rotation if enabled (avoids re-reading store per task)
+ const cachedAccounts = accountRotation ? await readAccountsFromStore() : null;
+
// Register CLI activity so desktop app knows this session is busy
registerCliActivity({
sessionId: session.id,
@@ -218,6 +261,7 @@ export async function* runPlaybook(
let totalCompletedTasks = 0;
let totalCost = 0;
let loopIteration = 0;
+ let globalTaskIndex = 0; // Used for account rotation round-robin
// Per-loop tracking
let loopStartTime = Date.now();
@@ -438,8 +482,17 @@ export async function* runPlaybook(
};
}
+ // Resolve account for this task (account multiplexing)
+ const configDir = await resolveAccountConfigDir(
+ globalTaskIndex,
+ accountOption,
+ accountRotation,
+ cachedAccounts,
+ );
+ globalTaskIndex++;
+
// Spawn agent with combined prompt + document
- const result = await spawnAgent(session.toolType, session.cwd, finalPrompt);
+ const result = await spawnAgent(session.toolType, session.cwd, finalPrompt, undefined, configDir);
const elapsedMs = Date.now() - taskStartTime;
@@ -471,12 +524,13 @@ export async function* runPlaybook(
let fullSynopsis = shortSummary;
if (result.success && result.agentSessionId) {
- // Request synopsis from the agent
+ // Request synopsis from the agent (same account as the task)
const synopsisResult = await spawnAgent(
session.toolType,
session.cwd,
BATCH_SYNOPSIS_PROMPT,
- result.agentSessionId
+ result.agentSessionId,
+ configDir
);
if (synopsisResult.success && synopsisResult.response) {
From e33845c643d6df429fd9a23356b24290675992e0 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 11:33:44 -0500
Subject: [PATCH 14/59] MAESTRO: feat: add account propagation to session
merge/transfer operations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wire up account multiplexing support for the MergeSessionModal and all
merge-related operations:
- Display source session account (green dot indicator) in token preview
- Show target session account in tab list items during selection
- Warn when merging across different accounts (info note about symlinks)
- Propagate accountId/accountName to new sessions created by merge
- Thread accountId through the full context grooming chain:
renderer → preload → IPC handler → context-groomer utility
- Add getAccountRegistry to ContextHandlerDependencies
Co-Authored-By: Claude Opus 4.6
---
src/main/index.ts | 1 +
src/main/ipc/handlers/context.ts | 7 ++-
src/main/ipc/handlers/index.ts | 3 ++
src/main/preload/context.ts | 2 +
src/renderer/components/MergeSessionModal.tsx | 51 +++++++++++++++++++
src/renderer/global.d.ts | 2 +
src/renderer/hooks/agent/useMergeSession.ts | 18 +++++++
src/renderer/services/contextGroomer.ts | 3 +-
src/renderer/types/contextMerge.ts | 2 +
9 files changed, 87 insertions(+), 2 deletions(-)
diff --git a/src/main/index.ts b/src/main/index.ts
index 8d3e0dedc..255e7fc7d 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -558,6 +558,7 @@ function setupIpcHandlers() {
getMainWindow: () => mainWindow,
getProcessManager: () => processManager,
getAgentDetector: () => agentDetector,
+ getAccountRegistry: () => accountRegistry,
});
// Register Marketplace handlers for fetching and importing playbooks
diff --git a/src/main/ipc/handlers/context.ts b/src/main/ipc/handlers/context.ts
index c427e8183..d73f757e9 100644
--- a/src/main/ipc/handlers/context.ts
+++ b/src/main/ipc/handlers/context.ts
@@ -24,6 +24,7 @@ import { getSessionStorage, type SessionMessagesResult } from '../../agents';
import { groomContext, cancelAllGroomingSessions } from '../../utils/context-groomer';
import type { ProcessManager } from '../../process-manager';
import type { AgentDetector } from '../../agents';
+import type { AccountRegistry } from '../../accounts/account-registry';
const LOG_CONTEXT = '[ContextMerge]';
@@ -47,6 +48,7 @@ export interface ContextHandlerDependencies {
getMainWindow: () => BrowserWindow | null;
getProcessManager: () => ProcessManager | null;
getAgentDetector: () => AgentDetector | null;
+ getAccountRegistry: () => AccountRegistry | null;
}
/**
@@ -77,7 +79,7 @@ const GROOMING_TIMEOUT_MS = 5 * 60 * 1000;
* - cleanupGroomingSession: Clean up a temporary grooming session
*/
export function registerContextHandlers(deps: ContextHandlerDependencies): void {
- const { getProcessManager, getAgentDetector } = deps;
+ const { getProcessManager, getAgentDetector, getAccountRegistry } = deps;
logger.info('Registering context IPC handlers', LOG_CONTEXT);
console.log('[ContextMerge] Registering context IPC handlers (v2 with response collection)');
@@ -145,6 +147,7 @@ export function registerContextHandlers(deps: ContextHandlerDependencies): void
customPath?: string;
customArgs?: string;
customEnvVars?: Record;
+ accountId?: string;
}
): Promise => {
const processManager = requireDependency(getProcessManager, 'Process manager');
@@ -161,6 +164,8 @@ export function registerContextHandlers(deps: ContextHandlerDependencies): void
sessionCustomPath: options?.customPath,
sessionCustomArgs: options?.customArgs,
sessionCustomEnvVars: options?.customEnvVars,
+ accountRegistry: getAccountRegistry() || undefined,
+ accountId: options?.accountId,
},
processManager,
agentDetector
diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts
index 96701f6d4..143884745 100644
--- a/src/main/ipc/handlers/index.ts
+++ b/src/main/ipc/handlers/index.ts
@@ -54,6 +54,7 @@ import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNa
import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes';
import { registerAccountHandlers, AccountHandlerDependencies } from './accounts';
import { AgentDetector } from '../../agents';
+import type { AccountRegistry } from '../../accounts/account-registry';
import { ProcessManager } from '../../process-manager';
import { WebServer } from '../../web-server';
import { tunnelManager as tunnelManagerInstance } from '../../tunnel-manager';
@@ -156,6 +157,7 @@ export interface HandlerDependencies {
tunnelManager: TunnelManagerType;
// Claude-specific dependencies
claudeSessionOriginsStore: Store;
+ getAccountRegistry?: () => AccountRegistry | null;
}
/**
@@ -229,6 +231,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
getMainWindow: deps.getMainWindow,
getProcessManager: deps.getProcessManager,
getAgentDetector: deps.getAgentDetector,
+ getAccountRegistry: deps.getAccountRegistry || (() => null),
});
// Register marketplace handlers
registerMarketplaceHandlers({
diff --git a/src/main/preload/context.ts b/src/main/preload/context.ts
index 59042bf1b..925335e7f 100644
--- a/src/main/preload/context.ts
+++ b/src/main/preload/context.ts
@@ -58,6 +58,8 @@ export function createContextApi() {
customPath?: string;
customArgs?: string;
customEnvVars?: Record;
+ // Account multiplexing
+ accountId?: string;
}
): Promise =>
ipcRenderer.invoke('context:groomContext', projectRoot, agentType, prompt, options),
diff --git a/src/renderer/components/MergeSessionModal.tsx b/src/renderer/components/MergeSessionModal.tsx
index 0600e439d..5d5c972c4 100644
--- a/src/renderer/components/MergeSessionModal.tsx
+++ b/src/renderer/components/MergeSessionModal.tsx
@@ -54,6 +54,8 @@ interface SessionListItem {
agentSessionId?: string;
estimatedTokens: number;
lastActivity?: number;
+ accountId?: string;
+ accountName?: string;
}
export interface MergeSessionModalProps {
@@ -286,6 +288,8 @@ export function MergeSessionModal({
estimatedTokens: estimateTokens(tab.logs),
lastActivity:
tab.logs.length > 0 ? Math.max(...tab.logs.map((l) => l.timestamp)) : tab.createdAt,
+ accountId: session.accountId,
+ accountName: session.accountName,
});
}
}
@@ -901,6 +905,11 @@ export function MergeSessionModal({
{item.agentSessionId.split('-')[0].toUpperCase()}
)}
+ {item.accountId && (
+
+ ({item.accountName || item.accountId})
+
+ )}
+ {sourceSession?.accountId && (
+
+
+ Account: {sourceSession.accountName || sourceSession.accountId}
+
+ )}
{(selectedTarget || (viewMode === 'paste' && pastedIdMatch)) && (
<>
@@ -993,6 +1021,29 @@ export function MergeSessionModal({
)}
+ {/* Account mismatch warning */}
+ {(() => {
+ const target = viewMode === 'paste' ? pastedIdMatch : selectedTarget;
+ if (sourceSession?.accountId && target?.accountId
+ && sourceSession.accountId !== target.accountId) {
+ return (
+
+ Note: Source and target sessions use different accounts
+ ({sourceSession.accountName || sourceSession.accountId} → {target.accountName || target.accountId}).
+ Session files are shared via symlinks, so this merge should work seamlessly.
+
+ );
+ }
+ return null;
+ })()}
+
{/* Options */}
Merge options
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 8cfa8115c..6c4f3ede1 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -181,6 +181,8 @@ interface MaestroAPI {
customPath?: string;
customArgs?: string;
customEnvVars?: Record;
+ // Account multiplexing
+ accountId?: string;
}
) => Promise;
// Cancel all active grooming sessions
diff --git a/src/renderer/hooks/agent/useMergeSession.ts b/src/renderer/hooks/agent/useMergeSession.ts
index 6e330714b..d479c8bad 100644
--- a/src/renderer/hooks/agent/useMergeSession.ts
+++ b/src/renderer/hooks/agent/useMergeSession.ts
@@ -365,10 +365,14 @@ export function useMergeSession(activeTabId?: string): UseMergeSessionResult {
},
});
+ // Use target session account, fallback to source
+ const groomAccountId = targetSession?.accountId || sourceSession?.accountId;
+
const groomingRequest: MergeRequest = {
sources: [sourceContext, targetContext],
targetAgent: sourceSession.toolType,
targetProjectRoot: sourceSession.projectRoot,
+ accountId: groomAccountId,
};
const groomingResult = await groomingServiceRef.current.groomContexts(
@@ -447,6 +451,13 @@ export function useMergeSession(activeTabId?: string): UseMergeSessionResult {
saveToHistory: true,
});
+ // Inherit account from target session, fallback to source
+ const mergeInheritFrom = targetSession?.accountId ? targetSession : sourceSession;
+ if (mergeInheritFrom?.accountId) {
+ mergedSession.accountId = mergeInheritFrom.accountId;
+ mergedSession.accountName = mergeInheritFrom.accountName;
+ }
+
result = {
success: true,
newSessionId: mergedSession.id,
@@ -674,6 +685,13 @@ export function useMergeSessionWithSessions(
groupId: sourceSession.groupId,
});
+ // Inherit account from target session, fallback to source
+ const inheritFrom = targetSession?.accountId ? targetSession : sourceSession;
+ if (inheritFrom?.accountId) {
+ newSession.accountId = inheritFrom.accountId;
+ newSession.accountName = inheritFrom.accountName;
+ }
+
// Add new session to state
setSessions((prev) => [...prev, newSession]);
diff --git a/src/renderer/services/contextGroomer.ts b/src/renderer/services/contextGroomer.ts
index c8abc901d..a5474132e 100644
--- a/src/renderer/services/contextGroomer.ts
+++ b/src/renderer/services/contextGroomer.ts
@@ -287,7 +287,8 @@ export class ContextGroomingService {
const groomedText = await window.maestro.context.groomContext(
targetProjectRoot,
request.targetAgent,
- prompt
+ prompt,
+ request.accountId ? { accountId: request.accountId } : undefined
);
onProgress({
diff --git a/src/renderer/types/contextMerge.ts b/src/renderer/types/contextMerge.ts
index bb908f235..99a803139 100644
--- a/src/renderer/types/contextMerge.ts
+++ b/src/renderer/types/contextMerge.ts
@@ -46,6 +46,8 @@ export interface MergeRequest {
targetProjectRoot: string;
/** Optional custom prompt for the grooming agent */
groomingPrompt?: string;
+ /** Account ID to inherit for the grooming agent (for account multiplexing) */
+ accountId?: string;
}
/**
From c89ee1d536c6feb95d0ef23d139f4bb8fd2d5485 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 15:00:37 -0500
Subject: [PATCH 15/59] MAESTRO: feat: add account display to ProcessMonitor,
SymphonyModal, and session UI surfaces
- ProcessMonitor: account badge on process list items and account info card in detail view
- SymphonyModal: account badge next to session name link on contribution cards
- SessionList: account info in tooltip content and non-clickable account entry in context menu
- All account UI gracefully hidden when no account is assigned
Co-Authored-By: Claude Opus 4.6
---
src/renderer/components/ProcessMonitor.tsx | 47 ++++++++++++++++++++++
src/renderer/components/SessionList.tsx | 17 ++++++++
src/renderer/components/SymphonyModal.tsx | 36 ++++++++++++-----
3 files changed, 91 insertions(+), 9 deletions(-)
diff --git a/src/renderer/components/ProcessMonitor.tsx b/src/renderer/components/ProcessMonitor.tsx
index 74df2d42a..d0b964305 100644
--- a/src/renderer/components/ProcessMonitor.tsx
+++ b/src/renderer/components/ProcessMonitor.tsx
@@ -14,6 +14,7 @@ import {
FolderOpen,
Hash,
Play,
+ User,
} from 'lucide-react';
import type { Session, Group, Theme, GroupChat } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
@@ -1002,6 +1003,28 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
GENERATING
)}
+ {/* Account badge — show if session has an account assigned */}
+ {(() => {
+ const sess = sessions.find(s => s.id === node.agentSessionId);
+ if (sess?.accountName) {
+ return (
+
+ {sess.accountName}
+
+ );
+ }
+ return null;
+ })()}
{/* Kill button */}
{node.processSessionId && (
+
+ {/* Account Info */}
+ {(() => {
+ const sess = sessions.find(s => s.id === detailView.agentSessionId);
+ if (sess?.accountId) {
+ return (
+
+
+
+
+ Account
+
+
+
+ {sess.accountName || sess.accountId}
+
+
+ );
+ }
+ return null;
+ })()}
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index d41127d7e..1c1027789 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -36,6 +36,7 @@ import {
Server,
Music,
Command,
+ User,
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import type {
@@ -209,6 +210,17 @@ function SessionContextMenu({
Edit Agent...
+ {/* Account info - non-clickable info item */}
+ {session.accountId && (
+
+
+ Account: {session.accountName || session.accountId}
+
+ )}
+
{/* Duplicate */}
{
@@ -934,6 +946,11 @@ const SessionTooltipContent = memo(function SessionTooltipContent({
{session.state} • {session.toolType}
{session.sessionSshRemoteConfig?.enabled ? ' (SSH)' : ''}
+ {session.accountName && (
+
+ Account: {session.accountName}
+
+ )}
void;
isSyncing: boolean;
sessionName: string | null;
+ accountName?: string | null;
onNavigateToSession: () => void;
}) {
const statusInfo = getStatusInfo(contribution.status);
@@ -968,15 +970,30 @@ function ActiveContributionCard({
{contribution.repoSlug}
{sessionName && (
-
-
- {sessionName}
-
+
+
+
+ {sessionName}
+
+ {accountName && (
+
+ {accountName}
+
+ )}
+
)}
@@ -2055,6 +2072,7 @@ export function SymphonyModal({
syncingContributionId === contribution.id
}
sessionName={session?.name ?? null}
+ accountName={session?.accountName}
onNavigateToSession={() => {
if (session) {
onSelectSession(session.id);
From fef1a3711193d243db1081382d15ff92ea6b2a28 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 17:47:55 -0500
Subject: [PATCH 16/59] MAESTRO: test: add comprehensive test suite for
AccountRecoveryPoller
Tests cover: timer-based recovery of throttled accounts past their window,
IPC event broadcasting (status-changed + recovery-available), multi-account
recovery with correct counts, edge cases (zero lastThrottledAt, missing
tokenWindowMs), and start/stop lifecycle including idempotency.
Co-Authored-By: Claude Opus 4.6
---
.../accounts/account-recovery-poller.test.ts | 293 ++++++++++++++++++
src/main/accounts/account-recovery-poller.ts | 134 ++++++++
2 files changed, 427 insertions(+)
create mode 100644 src/__tests__/main/accounts/account-recovery-poller.test.ts
create mode 100644 src/main/accounts/account-recovery-poller.ts
diff --git a/src/__tests__/main/accounts/account-recovery-poller.test.ts b/src/__tests__/main/accounts/account-recovery-poller.test.ts
new file mode 100644
index 000000000..2582a85d8
--- /dev/null
+++ b/src/__tests__/main/accounts/account-recovery-poller.test.ts
@@ -0,0 +1,293 @@
+/**
+ * Tests for AccountRecoveryPoller.
+ * Validates timer-based recovery of throttled accounts,
+ * IPC event broadcasting, and edge cases.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { AccountRecoveryPoller } from '../../../main/accounts/account-recovery-poller';
+import type { AccountRegistry } from '../../../main/accounts/account-registry';
+import type { AccountProfile } from '../../../shared/account-types';
+
+function createMockAccount(overrides: Partial = {}): AccountProfile {
+ return {
+ id: 'acct-1',
+ name: 'Test Account',
+ email: 'test@example.com',
+ configDir: '/home/test/.claude-test',
+ agentType: 'claude-code',
+ status: 'active',
+ authMethod: 'oauth',
+ addedAt: Date.now(),
+ lastUsedAt: Date.now(),
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: 5 * 60 * 60 * 1000, // 5 hours
+ isDefault: true,
+ autoSwitchEnabled: true,
+ ...overrides,
+ };
+}
+
+describe('AccountRecoveryPoller', () => {
+ let poller: AccountRecoveryPoller;
+ let mockRegistry: {
+ getAll: ReturnType;
+ setStatus: ReturnType;
+ };
+ let mockSafeSend: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+
+ mockRegistry = {
+ getAll: vi.fn().mockReturnValue([]),
+ setStatus: vi.fn(),
+ };
+ mockSafeSend = vi.fn();
+
+ poller = new AccountRecoveryPoller(
+ {
+ accountRegistry: mockRegistry as unknown as AccountRegistry,
+ safeSend: mockSafeSend,
+ },
+ 60_000 // 1 minute interval
+ );
+ });
+
+ afterEach(() => {
+ poller.stop();
+ vi.useRealTimers();
+ });
+
+ it('should return empty array when no throttled accounts exist', () => {
+ mockRegistry.getAll.mockReturnValue([createMockAccount({ status: 'active' })]);
+
+ const recovered = poller.poll();
+
+ expect(recovered).toEqual([]);
+ expect(mockRegistry.setStatus).not.toHaveBeenCalled();
+ expect(mockSafeSend).not.toHaveBeenCalled();
+ });
+
+ it('should not recover accounts still within their throttle window', () => {
+ const now = Date.now();
+ const windowMs = 5 * 60 * 60 * 1000; // 5 hours
+
+ mockRegistry.getAll.mockReturnValue([
+ createMockAccount({
+ id: 'acct-1',
+ status: 'throttled',
+ lastThrottledAt: now - (windowMs / 2), // Only halfway through window
+ tokenWindowMs: windowMs,
+ }),
+ ]);
+
+ const recovered = poller.poll();
+
+ expect(recovered).toEqual([]);
+ expect(mockRegistry.setStatus).not.toHaveBeenCalled();
+ });
+
+ it('should recover accounts past their window + margin', () => {
+ const now = Date.now();
+ const windowMs = 5 * 60 * 60 * 1000; // 5 hours
+ const marginMs = 30_000; // 30 seconds
+
+ mockRegistry.getAll.mockReturnValue([
+ createMockAccount({
+ id: 'acct-1',
+ name: 'Recovered Account',
+ status: 'throttled',
+ lastThrottledAt: now - windowMs - marginMs - 1000, // Past window + margin
+ tokenWindowMs: windowMs,
+ }),
+ ]);
+
+ const recovered = poller.poll();
+
+ expect(recovered).toEqual(['acct-1']);
+ expect(mockRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'active');
+ });
+
+ it('should broadcast status-changed and recovery-available events on recovery', () => {
+ const now = Date.now();
+ const windowMs = 5 * 60 * 60 * 1000;
+ const marginMs = 30_000;
+
+ mockRegistry.getAll.mockReturnValue([
+ createMockAccount({
+ id: 'acct-1',
+ name: 'Recovered Account',
+ status: 'throttled',
+ lastThrottledAt: now - windowMs - marginMs - 1000,
+ tokenWindowMs: windowMs,
+ }),
+ ]);
+
+ poller.poll();
+
+ // Should send status-changed per recovered account
+ expect(mockSafeSend).toHaveBeenCalledWith('account:status-changed', expect.objectContaining({
+ accountId: 'acct-1',
+ accountName: 'Recovered Account',
+ oldStatus: 'throttled',
+ newStatus: 'active',
+ recoveredBy: 'poller',
+ }));
+
+ // Should send recovery-available summary event
+ expect(mockSafeSend).toHaveBeenCalledWith('account:recovery-available', expect.objectContaining({
+ recoveredAccountIds: ['acct-1'],
+ recoveredCount: 1,
+ stillThrottledCount: 0,
+ totalAccounts: 1,
+ }));
+ });
+
+ it('should recover multiple accounts and report correct counts', () => {
+ const now = Date.now();
+ const windowMs = 5 * 60 * 60 * 1000;
+ const marginMs = 30_000;
+
+ mockRegistry.getAll.mockReturnValue([
+ createMockAccount({
+ id: 'acct-1',
+ name: 'Account 1',
+ status: 'throttled',
+ lastThrottledAt: now - windowMs - marginMs - 1000,
+ tokenWindowMs: windowMs,
+ }),
+ createMockAccount({
+ id: 'acct-2',
+ name: 'Account 2',
+ status: 'throttled',
+ lastThrottledAt: now - windowMs - marginMs - 2000,
+ tokenWindowMs: windowMs,
+ }),
+ createMockAccount({
+ id: 'acct-3',
+ name: 'Account 3 (still throttled)',
+ status: 'throttled',
+ lastThrottledAt: now - (windowMs / 2), // Still within window
+ tokenWindowMs: windowMs,
+ }),
+ ]);
+
+ const recovered = poller.poll();
+
+ expect(recovered).toEqual(['acct-1', 'acct-2']);
+ expect(mockRegistry.setStatus).toHaveBeenCalledTimes(2);
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:recovery-available', expect.objectContaining({
+ recoveredAccountIds: ['acct-1', 'acct-2'],
+ recoveredCount: 2,
+ stillThrottledCount: 1,
+ totalAccounts: 3,
+ }));
+ });
+
+ it('should skip throttled accounts with lastThrottledAt of 0', () => {
+ mockRegistry.getAll.mockReturnValue([
+ createMockAccount({
+ id: 'acct-1',
+ status: 'throttled',
+ lastThrottledAt: 0, // Never throttled (invalid state)
+ }),
+ ]);
+
+ const recovered = poller.poll();
+
+ expect(recovered).toEqual([]);
+ expect(mockRegistry.setStatus).not.toHaveBeenCalled();
+ });
+
+ it('should use DEFAULT_TOKEN_WINDOW_MS when tokenWindowMs is 0', () => {
+ const now = Date.now();
+ const defaultWindowMs = 5 * 60 * 60 * 1000; // 5 hours (DEFAULT_TOKEN_WINDOW_MS)
+ const marginMs = 30_000;
+
+ mockRegistry.getAll.mockReturnValue([
+ createMockAccount({
+ id: 'acct-1',
+ name: 'No Window Account',
+ status: 'throttled',
+ lastThrottledAt: now - defaultWindowMs - marginMs - 1000,
+ tokenWindowMs: 0, // Will use default
+ }),
+ ]);
+
+ const recovered = poller.poll();
+
+ expect(recovered).toEqual(['acct-1']);
+ });
+
+ // --- Start/stop behavior ---
+
+ it('should start and stop cleanly', () => {
+ expect(poller.isRunning()).toBe(false);
+
+ poller.start();
+ expect(poller.isRunning()).toBe(true);
+
+ poller.stop();
+ expect(poller.isRunning()).toBe(false);
+ });
+
+ it('should be idempotent on start (no double timers)', () => {
+ mockRegistry.getAll.mockReturnValue([]);
+
+ poller.start();
+ poller.start(); // Second call should be no-op
+
+ expect(poller.isRunning()).toBe(true);
+
+ // Advance time and verify poll is called (not doubled)
+ vi.advanceTimersByTime(60_000);
+
+ // getAll is called once on immediate poll (start), then once per interval tick
+ // start() → poll() → getAll() [1st call]
+ // 2nd start() → no-op
+ // advanceTimersByTime(60_000) → poll() → getAll() [2nd call]
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(2);
+ });
+
+ it('should stop safely when not running', () => {
+ expect(() => poller.stop()).not.toThrow();
+ });
+
+ it('should run poll immediately on start', () => {
+ mockRegistry.getAll.mockReturnValue([]);
+
+ poller.start();
+
+ // poll() is called immediately in start()
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(1);
+ });
+
+ it('should poll on interval after start', () => {
+ mockRegistry.getAll.mockReturnValue([]);
+
+ poller.start();
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(1); // Immediate poll
+
+ vi.advanceTimersByTime(60_000);
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(2); // First interval
+
+ vi.advanceTimersByTime(60_000);
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(3); // Second interval
+ });
+
+ it('should stop polling after stop() is called', () => {
+ mockRegistry.getAll.mockReturnValue([]);
+
+ poller.start();
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(1);
+
+ poller.stop();
+
+ vi.advanceTimersByTime(120_000);
+ expect(mockRegistry.getAll).toHaveBeenCalledTimes(1); // No more calls
+ });
+});
diff --git a/src/main/accounts/account-recovery-poller.ts b/src/main/accounts/account-recovery-poller.ts
new file mode 100644
index 000000000..88c48a2bd
--- /dev/null
+++ b/src/main/accounts/account-recovery-poller.ts
@@ -0,0 +1,134 @@
+/**
+ * Account Recovery Poller
+ *
+ * Timer-based service that proactively checks whether throttled accounts
+ * have passed their rate-limit window and can be recovered to active status.
+ *
+ * This solves the "all accounts exhausted" deadlock: when every configured
+ * account is throttled, no usage events fire (no agents running), so the
+ * passive recovery in account-usage-listener never triggers. This poller
+ * runs independently on a fixed interval.
+ */
+
+import type { AccountRegistry } from './account-registry';
+import type { AccountProfile } from '../../shared/account-types';
+import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types';
+import type { SafeSendFn } from '../utils/safe-send';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'account-recovery-poller';
+
+/** How often to check throttled accounts (ms) */
+const DEFAULT_POLL_INTERVAL_MS = 60_000; // 1 minute
+
+/** Minimum time after throttle before considering recovery (safety margin) */
+const RECOVERY_MARGIN_MS = 30_000; // 30 seconds past window
+
+export interface AccountRecoveryPollerDeps {
+ accountRegistry: AccountRegistry;
+ safeSend: SafeSendFn;
+}
+
+export class AccountRecoveryPoller {
+ private timer: ReturnType | null = null;
+ private pollIntervalMs: number;
+ private deps: AccountRecoveryPollerDeps;
+
+ constructor(deps: AccountRecoveryPollerDeps, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS) {
+ this.deps = deps;
+ this.pollIntervalMs = pollIntervalMs;
+ }
+
+ /**
+ * Start the poller. Safe to call multiple times (idempotent).
+ */
+ start(): void {
+ if (this.timer) return;
+
+ logger.info('Starting account recovery poller', LOG_CONTEXT, {
+ intervalMs: this.pollIntervalMs,
+ });
+
+ // Run immediately on start, then on interval
+ this.poll();
+ this.timer = setInterval(() => this.poll(), this.pollIntervalMs);
+ }
+
+ /**
+ * Stop the poller. Safe to call when not running.
+ */
+ stop(): void {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ logger.info('Stopped account recovery poller', LOG_CONTEXT);
+ }
+ }
+
+ /**
+ * Check all throttled accounts and recover those past their window.
+ * Returns the list of recovered account IDs.
+ */
+ poll(): string[] {
+ const { accountRegistry, safeSend } = this.deps;
+ const now = Date.now();
+ const recovered: string[] = [];
+
+ const throttledAccounts = accountRegistry.getAll().filter(
+ (a: AccountProfile) => a.status === 'throttled' && a.lastThrottledAt > 0
+ );
+
+ if (throttledAccounts.length === 0) return recovered;
+
+ for (const account of throttledAccounts) {
+ const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS;
+ const timeSinceThrottle = now - account.lastThrottledAt;
+
+ // Recover if enough time has passed (window + safety margin)
+ if (timeSinceThrottle > windowMs + RECOVERY_MARGIN_MS) {
+ accountRegistry.setStatus(account.id, 'active');
+ recovered.push(account.id);
+
+ logger.info(`Account ${account.name} recovered from throttle via poller`, LOG_CONTEXT, {
+ accountId: account.id,
+ timeSinceThrottleMs: timeSinceThrottle,
+ windowMs,
+ });
+
+ safeSend('account:status-changed', {
+ accountId: account.id,
+ accountName: account.name,
+ oldStatus: 'throttled',
+ newStatus: 'active',
+ recoveredBy: 'poller',
+ });
+ }
+ }
+
+ // If any accounts recovered, also broadcast a recovery summary event
+ // so the renderer can auto-resume paused sessions
+ if (recovered.length > 0) {
+ const totalAccounts = accountRegistry.getAll().length;
+ const stillThrottled = throttledAccounts.length - recovered.length;
+
+ safeSend('account:recovery-available', {
+ recoveredAccountIds: recovered,
+ recoveredCount: recovered.length,
+ stillThrottledCount: stillThrottled,
+ totalAccounts,
+ });
+
+ logger.info(`Recovery poll: ${recovered.length} account(s) recovered`, LOG_CONTEXT, {
+ recovered: recovered.length,
+ stillThrottled,
+ });
+ }
+
+ return recovered;
+ }
+
+ /** Check if the poller is currently running */
+ isRunning(): boolean {
+ return this.timer !== null;
+ }
+}
From 7586956d429198bf4665c53a5f14c85c9780159f Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 17:54:22 -0500
Subject: [PATCH 17/59] MAESTRO: feat: add capacity-aware account selection to
AccountRegistry
- Add `selectByRemainingCapacity()` method that routes to the account
with the most remaining token capacity in its current usage window
- Extract shared `getWindowBounds()` utility to `account-utils.ts`
(was duplicated in throttle handler, usage listener, and IPC handlers)
- Accept optional `AccountUsageStatsProvider` in `selectNextAccount()`
for capacity-aware routing; falls back to LRU when unavailable
- Apply 50% capacity penalty to recently-throttled accounts (within 2
windows) to prevent ping-ponging
- Accounts without configured limits are deprioritized behind accounts
with known remaining capacity
- Round-robin strategy remains deterministic and ignores usage stats
- Add 7 new tests covering capacity-aware selection scenarios
Co-Authored-By: Claude Opus 4.6
---
.../main/accounts/account-registry.test.ts | 125 ++++++++++++++++++
src/main/accounts/account-registry.ts | 87 +++++++++++-
src/main/accounts/account-throttle-handler.ts | 15 +--
src/main/accounts/account-utils.ts | 17 +++
4 files changed, 227 insertions(+), 17 deletions(-)
create mode 100644 src/main/accounts/account-utils.ts
diff --git a/src/__tests__/main/accounts/account-registry.test.ts b/src/__tests__/main/accounts/account-registry.test.ts
index df77f4897..847ae46d8 100644
--- a/src/__tests__/main/accounts/account-registry.test.ts
+++ b/src/__tests__/main/accounts/account-registry.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AccountRegistry } from '../../../main/accounts/account-registry';
+import type { AccountUsageStatsProvider } from '../../../main/accounts/account-registry';
import type { AccountStoreData } from '../../../main/stores/account-store-types';
import { ACCOUNT_SWITCH_DEFAULTS } from '../../../shared/account-types';
@@ -341,6 +342,130 @@ describe('AccountRegistry', () => {
expect([first?.id, second?.id]).toContain(a.id);
expect([first?.id, second?.id]).toContain(b.id);
});
+
+ describe('capacity-aware selection', () => {
+ function createMockStatsDB(usageMap: Record): AccountUsageStatsProvider {
+ return {
+ isReady: () => true,
+ getAccountUsageInWindow: (id: string) => {
+ const total = usageMap[id] ?? 0;
+ return {
+ inputTokens: total,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheCreationTokens: 0,
+ };
+ },
+ };
+ }
+
+ it('should select account with most remaining capacity', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.update(a.id, { tokenLimitPerWindow: 10000 });
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ registry.update(b.id, { tokenLimitPerWindow: 10000 });
+
+ // A used 8000, B used 2000 → B has more remaining
+ const statsDB = createMockStatsDB({ [a.id]: 8000, [b.id]: 2000 });
+ const next = registry.selectNextAccount([], statsDB);
+
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should fall back to LRU when statsDB is not ready', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.update(a.id, { tokenLimitPerWindow: 10000 });
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ registry.update(b.id, { tokenLimitPerWindow: 10000 });
+
+ registry.touchLastUsed(a.id);
+
+ const statsDB: AccountUsageStatsProvider = {
+ isReady: () => false,
+ getAccountUsageInWindow: () => ({
+ inputTokens: 0, outputTokens: 0,
+ cacheReadTokens: 0, cacheCreationTokens: 0,
+ }),
+ };
+
+ const next = registry.selectNextAccount([], statsDB);
+ expect(next?.id).toBe(b.id); // b has lower lastUsedAt
+ });
+
+ it('should deprioritize recently throttled accounts', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.update(a.id, { tokenLimitPerWindow: 10000 });
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ registry.update(b.id, { tokenLimitPerWindow: 10000 });
+
+ // A has more remaining capacity but was recently throttled
+ registry.update(a.id, { lastThrottledAt: Date.now() - 60_000 }); // 1 min ago
+ const statsDB = createMockStatsDB({ [a.id]: 2000, [b.id]: 4000 });
+
+ const next = registry.selectNextAccount([], statsDB);
+ // A remaining: (10000-2000)*0.5 = 4000 (penalized)
+ // B remaining: 10000-4000 = 6000
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should prefer accounts with configured limits over unlimited', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.update(a.id, { tokenLimitPerWindow: 10000 });
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ // b has no limit (tokenLimitPerWindow = 0)
+
+ const statsDB = createMockStatsDB({ [a.id]: 2000, [b.id]: 0 });
+ const next = registry.selectNextAccount([], statsDB);
+
+ // A has known remaining capacity (8000), B is Infinity but deprioritized
+ expect(next?.id).toBe(a.id);
+ });
+
+ it('should fall back to LRU when no accounts have limits', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ // Neither has tokenLimitPerWindow set
+
+ registry.touchLastUsed(a.id);
+
+ const statsDB = createMockStatsDB({ [a.id]: 5000, [b.id]: 1000 });
+ const next = registry.selectNextAccount([], statsDB);
+
+ // Both have Infinity remaining → falls back to LRU → b (lastUsedAt=0) wins
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should select only remaining account when one is at capacity', () => {
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.update(a.id, { tokenLimitPerWindow: 10000 });
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ registry.update(b.id, { tokenLimitPerWindow: 10000 });
+
+ // A is at 100% capacity, B has headroom
+ const statsDB = createMockStatsDB({ [a.id]: 15000, [b.id]: 3000 });
+ const next = registry.selectNextAccount([], statsDB);
+
+ expect(next?.id).toBe(b.id);
+ });
+
+ it('should not use capacity-aware selection for round-robin strategy', () => {
+ registry.updateSwitchConfig({ selectionStrategy: 'round-robin' });
+
+ const a = registry.add(makeParams({ email: 'a@example.com' }));
+ registry.update(a.id, { tokenLimitPerWindow: 10000 });
+ const b = registry.add(makeParams({ email: 'b@example.com' }));
+ registry.update(b.id, { tokenLimitPerWindow: 10000 });
+
+ // Even though A has way more usage, round-robin should still cycle deterministically
+ const statsDB = createMockStatsDB({ [a.id]: 9000, [b.id]: 1000 });
+ const first = registry.selectNextAccount([], statsDB);
+ const second = registry.selectNextAccount([], statsDB);
+
+ // Both should appear (round-robin cycles)
+ expect([first?.id, second?.id]).toContain(a.id);
+ expect([first?.id, second?.id]).toContain(b.id);
+ });
+ });
});
describe('reconcileAssignments', () => {
diff --git a/src/main/accounts/account-registry.ts b/src/main/accounts/account-registry.ts
index 98d0bc077..4c87cf66c 100644
--- a/src/main/accounts/account-registry.ts
+++ b/src/main/accounts/account-registry.ts
@@ -10,6 +10,18 @@ import type {
import { DEFAULT_TOKEN_WINDOW_MS, ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
import { generateUUID } from '../../shared/uuid';
import { logger } from '../utils/logger';
+import { getWindowBounds } from './account-utils';
+
+/** Minimal interface for usage stats queries — avoids hard dependency on StatsDB */
+export interface AccountUsageStatsProvider {
+ getAccountUsageInWindow(id: string, start: number, end: number): {
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ };
+ isReady(): boolean;
+}
const LOG_CONTEXT = 'AccountRegistry';
@@ -188,8 +200,12 @@ export class AccountRegistry {
?? null;
}
- /** Select the next account using the configured strategy */
- selectNextAccount(excludeIds: AccountId[] = []): AccountProfile | null {
+ /**
+ * Select the next account using the configured strategy.
+ * When statsDB is provided, uses actual token consumption for routing.
+ * Falls back to lastUsedAt-based selection when statsDB is unavailable.
+ */
+ selectNextAccount(excludeIds: AccountId[] = [], statsDB?: AccountUsageStatsProvider): AccountProfile | null {
const config = this.getSwitchConfig();
const available = this.getAll().filter(
a => a.status === 'active' && a.autoSwitchEnabled && !excludeIds.includes(a.id)
@@ -206,11 +222,76 @@ export class AccountRegistry {
return available.find(a => a.id === order[idx]) ?? available[0];
}
- // least-used: sort by lastUsedAt ascending (least recently used first)
+ // least-used: prefer capacity-aware selection when statsDB is available
+ if (statsDB && statsDB.isReady()) {
+ return this.selectByRemainingCapacity(available, statsDB);
+ }
+
+ // Fallback: sort by lastUsedAt ascending (least recently used first)
available.sort((a, b) => a.lastUsedAt - b.lastUsedAt);
return available[0];
}
+ /**
+ * Select the account with the most remaining capacity in its current window.
+ * Accounts without configured limits are treated as having infinite remaining capacity,
+ * but deprioritized behind accounts with known remaining capacity.
+ */
+ private selectByRemainingCapacity(
+ accounts: AccountProfile[],
+ statsDB: AccountUsageStatsProvider,
+ ): AccountProfile {
+ const now = Date.now();
+
+ const scored = accounts.map(account => {
+ const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS;
+ const { start: windowStart, end: windowEnd } = getWindowBounds(now, windowMs);
+
+ const usage = statsDB.getAccountUsageInWindow(account.id, windowStart, windowEnd);
+ const totalTokens = usage.inputTokens + usage.outputTokens
+ + usage.cacheReadTokens + usage.cacheCreationTokens;
+
+ let remainingCapacity: number;
+
+ if (account.tokenLimitPerWindow > 0) {
+ remainingCapacity = Math.max(0, account.tokenLimitPerWindow - totalTokens);
+ } else {
+ remainingCapacity = Infinity;
+ }
+
+ // Deprioritize accounts that were recently throttled (within last 2 windows)
+ const recentThrottlePenalty = account.lastThrottledAt > 0
+ && (now - account.lastThrottledAt) < windowMs * 2
+ ? 0.5
+ : 1.0;
+
+ return {
+ account,
+ remainingCapacity: remainingCapacity === Infinity
+ ? Infinity
+ : remainingCapacity * recentThrottlePenalty,
+ };
+ });
+
+ // Sort: most remaining capacity first
+ const hasLimits = scored.some(s => s.remainingCapacity !== Infinity);
+
+ if (hasLimits) {
+ scored.sort((a, b) => {
+ // Finite capacity always before infinite
+ if (a.remainingCapacity === Infinity && b.remainingCapacity !== Infinity) return 1;
+ if (a.remainingCapacity !== Infinity && b.remainingCapacity === Infinity) return -1;
+ // Both finite: higher remaining first
+ return (b.remainingCapacity as number) - (a.remainingCapacity as number);
+ });
+ } else {
+ // No limits configured on any account — fall back to LRU
+ scored.sort((a, b) => a.account.lastUsedAt - b.account.lastUsedAt);
+ }
+
+ return scored[0].account;
+ }
+
// --- Reconciliation ---
/**
diff --git a/src/main/accounts/account-throttle-handler.ts b/src/main/accounts/account-throttle-handler.ts
index b04edabb1..18271665d 100644
--- a/src/main/accounts/account-throttle-handler.ts
+++ b/src/main/accounts/account-throttle-handler.ts
@@ -12,6 +12,7 @@
import type { AccountRegistry } from './account-registry';
import type { StatsDB } from '../stats';
import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types';
+import { getWindowBounds } from './account-utils';
const LOG_CONTEXT = 'account-throttle';
@@ -22,20 +23,6 @@ export interface ThrottleContext {
errorMessage: string;
}
-/**
- * Calculate the window boundaries for a given timestamp and window size.
- * Windows are aligned to fixed intervals from midnight.
- */
-function getWindowBounds(timestamp: number, windowMs: number): { start: number; end: number } {
- const dayStart = new Date(timestamp);
- dayStart.setHours(0, 0, 0, 0);
- const dayStartMs = dayStart.getTime();
- const windowsSinceDayStart = Math.floor((timestamp - dayStartMs) / windowMs);
- const start = dayStartMs + windowsSinceDayStart * windowMs;
- const end = start + windowMs;
- return { start, end };
-}
-
export class AccountThrottleHandler {
constructor(
private accountRegistry: AccountRegistry,
diff --git a/src/main/accounts/account-utils.ts b/src/main/accounts/account-utils.ts
new file mode 100644
index 000000000..5aad7e41d
--- /dev/null
+++ b/src/main/accounts/account-utils.ts
@@ -0,0 +1,17 @@
+/**
+ * Shared utilities for account multiplexing.
+ */
+
+/**
+ * Calculate the window boundaries for a given timestamp and window size.
+ * Windows are aligned to fixed intervals from midnight.
+ */
+export function getWindowBounds(timestamp: number, windowMs: number): { start: number; end: number } {
+ const dayStart = new Date(timestamp);
+ dayStart.setHours(0, 0, 0, 0);
+ const dayStartMs = dayStart.getTime();
+ const windowsSinceDayStart = Math.floor((timestamp - dayStartMs) / windowMs);
+ const start = dayStartMs + windowsSinceDayStart * windowMs;
+ const end = start + windowMs;
+ return { start, end };
+}
From 44e609a35a3ba06c63a77af24c17bd28f924d95c Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 17:59:18 -0500
Subject: [PATCH 18/59] MAESTRO: feat: wire statsDB into selectNextAccount
callers for capacity-aware routing
All call sites that invoke selectNextAccount() now pass the optional statsDB
parameter, enabling capacity-aware account selection when the stats database
is available. This allows the least-used strategy to route to accounts with
the most remaining token capacity rather than just LRU ordering.
Callers updated:
- account-env-injector: accepts optional getStatsDB function, passes to selectNextAccount
- account-throttle-handler: passes its existing getStatsDB to selectNextAccount
- accounts IPC handler (accounts:select-next): passes getStatsDB singleton
- process handler (process:spawn): passes getStatsDB via injectAccountEnv
Co-Authored-By: Claude Opus 4.6
---
.../accounts/account-env-injector.test.ts | 222 ++++++++++++++++++
src/main/accounts/account-env-injector.ts | 31 ++-
src/main/accounts/account-throttle-handler.ts | 8 +-
src/main/ipc/handlers/accounts.ts | 42 +++-
src/main/ipc/handlers/process.ts | 12 +-
5 files changed, 307 insertions(+), 8 deletions(-)
create mode 100644 src/__tests__/main/accounts/account-env-injector.test.ts
diff --git a/src/__tests__/main/accounts/account-env-injector.test.ts b/src/__tests__/main/accounts/account-env-injector.test.ts
new file mode 100644
index 000000000..aeb68e74c
--- /dev/null
+++ b/src/__tests__/main/accounts/account-env-injector.test.ts
@@ -0,0 +1,222 @@
+/**
+ * Tests for injectAccountEnv.
+ * Validates account injection, statsDB passthrough to selectNextAccount,
+ * and fallback behavior when statsDB is unavailable.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { AccountProfile } from '../../../shared/account-types';
+import type { AccountRegistry, AccountUsageStatsProvider } from '../../../main/accounts/account-registry';
+
+// Hoist mocks
+const { mockExistsSync } = vi.hoisted(() => ({
+ mockExistsSync: vi.fn(),
+}));
+
+vi.mock('fs', () => ({
+ existsSync: mockExistsSync,
+ default: { existsSync: mockExistsSync },
+}));
+
+vi.mock('../../../main/accounts/account-setup', () => ({
+ syncCredentialsFromBase: vi.fn().mockResolvedValue({ success: true }),
+}));
+
+vi.mock('../../../main/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+function createMockAccount(overrides: Partial = {}): AccountProfile {
+ return {
+ id: 'acct-1',
+ name: 'Test Account',
+ email: 'test@example.com',
+ configDir: '/home/test/.claude-test',
+ agentType: 'claude-code',
+ status: 'active',
+ authMethod: 'oauth',
+ addedAt: Date.now(),
+ lastUsedAt: 0,
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ isDefault: false,
+ autoSwitchEnabled: true,
+ ...overrides,
+ };
+}
+
+describe('injectAccountEnv', () => {
+ let mockRegistry: {
+ getAll: ReturnType;
+ get: ReturnType;
+ getAssignment: ReturnType;
+ getDefaultAccount: ReturnType;
+ selectNextAccount: ReturnType;
+ assignToSession: ReturnType;
+ };
+ let mockSafeSend: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockExistsSync.mockReturnValue(true);
+
+ mockRegistry = {
+ getAll: vi.fn().mockReturnValue([createMockAccount()]),
+ get: vi.fn().mockReturnValue(createMockAccount()),
+ getAssignment: vi.fn().mockReturnValue(null),
+ getDefaultAccount: vi.fn().mockReturnValue(null),
+ selectNextAccount: vi.fn().mockReturnValue(createMockAccount()),
+ assignToSession: vi.fn().mockReturnValue({ sessionId: 'sess-1', accountId: 'acct-1', assignedAt: Date.now() }),
+ };
+
+ mockSafeSend = vi.fn();
+ });
+
+ async function loadInjector() {
+ // Dynamic import to get fresh module after mocks are set up
+ const mod = await import('../../../main/accounts/account-env-injector');
+ return mod.injectAccountEnv;
+ }
+
+ it('should return null for non-claude-code agents', async () => {
+ const injectAccountEnv = await loadInjector();
+ const env: Record = {};
+ const result = injectAccountEnv(
+ 'sess-1', 'terminal', env,
+ mockRegistry as unknown as AccountRegistry,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('should respect existing CLAUDE_CONFIG_DIR in env', async () => {
+ const injectAccountEnv = await loadInjector();
+ const env: Record = { CLAUDE_CONFIG_DIR: '/custom/dir' };
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ );
+ expect(result).toBeNull();
+ expect(env.CLAUDE_CONFIG_DIR).toBe('/custom/dir');
+ });
+
+ it('should return null when no active accounts exist', async () => {
+ const injectAccountEnv = await loadInjector();
+ mockRegistry.getAll.mockReturnValue([]);
+ const env: Record = {};
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ );
+ expect(result).toBeNull();
+ });
+
+ it('should use provided accountId when specified', async () => {
+ const injectAccountEnv = await loadInjector();
+ const env: Record = {};
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ 'acct-1',
+ );
+ expect(result).toBe('acct-1');
+ expect(env.CLAUDE_CONFIG_DIR).toBe('/home/test/.claude-test');
+ expect(mockRegistry.selectNextAccount).not.toHaveBeenCalled();
+ });
+
+ it('should call selectNextAccount without statsDB when getStatsDB is not provided', async () => {
+ const injectAccountEnv = await loadInjector();
+ mockRegistry.getDefaultAccount.mockReturnValue(null);
+ const env: Record = {};
+
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ undefined,
+ mockSafeSend,
+ );
+
+ expect(result).toBe('acct-1');
+ expect(mockRegistry.selectNextAccount).toHaveBeenCalledWith([], undefined);
+ });
+
+ it('should pass statsDB to selectNextAccount when getStatsDB is provided', async () => {
+ const injectAccountEnv = await loadInjector();
+ mockRegistry.getDefaultAccount.mockReturnValue(null);
+ const env: Record = {};
+
+ const mockStatsDB: AccountUsageStatsProvider = {
+ getAccountUsageInWindow: vi.fn(),
+ isReady: vi.fn().mockReturnValue(true),
+ };
+
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ undefined,
+ mockSafeSend,
+ () => mockStatsDB,
+ );
+
+ expect(result).toBe('acct-1');
+ expect(mockRegistry.selectNextAccount).toHaveBeenCalledWith([], mockStatsDB);
+ });
+
+ it('should pass undefined to selectNextAccount when getStatsDB returns null', async () => {
+ const injectAccountEnv = await loadInjector();
+ mockRegistry.getDefaultAccount.mockReturnValue(null);
+ const env: Record = {};
+
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ undefined,
+ mockSafeSend,
+ () => null,
+ );
+
+ expect(result).toBe('acct-1');
+ expect(mockRegistry.selectNextAccount).toHaveBeenCalledWith([], undefined);
+ });
+
+ it('should skip selectNextAccount when default account exists', async () => {
+ const injectAccountEnv = await loadInjector();
+ const defaultAccount = createMockAccount({ id: 'default-1', name: 'Default' });
+ mockRegistry.getDefaultAccount.mockReturnValue(defaultAccount);
+ mockRegistry.get.mockReturnValue(defaultAccount);
+ const env: Record = {};
+
+ const result = injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ undefined,
+ mockSafeSend,
+ );
+
+ expect(result).toBe('default-1');
+ expect(mockRegistry.selectNextAccount).not.toHaveBeenCalled();
+ });
+
+ it('should notify renderer via safeSend when account is assigned', async () => {
+ const injectAccountEnv = await loadInjector();
+ const env: Record = {};
+
+ injectAccountEnv(
+ 'sess-1', 'claude-code', env,
+ mockRegistry as unknown as AccountRegistry,
+ 'acct-1',
+ mockSafeSend,
+ );
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:assigned', {
+ sessionId: 'sess-1',
+ accountId: 'acct-1',
+ accountName: 'Test Account',
+ });
+ });
+});
diff --git a/src/main/accounts/account-env-injector.ts b/src/main/accounts/account-env-injector.ts
index 4d3fac7b5..a184bd176 100644
--- a/src/main/accounts/account-env-injector.ts
+++ b/src/main/accounts/account-env-injector.ts
@@ -9,8 +9,11 @@
* - Session resume
*/
-import type { AccountRegistry } from './account-registry';
+import * as fs from 'fs';
+import * as path from 'path';
+import type { AccountRegistry, AccountUsageStatsProvider } from './account-registry';
import type { SafeSendFn } from '../utils/safe-send';
+import { syncCredentialsFromBase } from './account-setup';
import { logger } from '../utils/logger';
const LOG_CONTEXT = 'account-env-injector';
@@ -23,12 +26,17 @@ interface SpawnEnv {
* Injects CLAUDE_CONFIG_DIR into spawn environment for account multiplexing.
* Called by all code paths that spawn Claude Code agents.
*
+ * Does NOT validate credential freshness — Claude Code handles its own
+ * token refresh via the OAuth refresh token in .credentials.json.
+ * If the refresh fails, the error listener catches the auth error.
+ *
* @param sessionId - The session ID being spawned
* @param agentType - The agent type (only 'claude-code' is handled)
* @param env - Mutable env object to inject into
* @param accountRegistry - The account registry instance
* @param accountId - Pre-assigned account ID (optional, auto-assigns if missing)
* @param safeSend - Optional safeSend function to notify renderer of assignment
+ * @param getStatsDB - Optional function to get stats DB for capacity-aware selection
* @returns The account ID used (or null if no accounts configured)
*/
export function injectAccountEnv(
@@ -38,6 +46,7 @@ export function injectAccountEnv(
accountRegistry: AccountRegistry,
accountId?: string | null,
safeSend?: SafeSendFn,
+ getStatsDB?: () => AccountUsageStatsProvider | null,
): string | null {
if (agentType !== 'claude-code') return null;
@@ -65,7 +74,8 @@ export function injectAccountEnv(
}
if (!resolvedAccountId) {
const defaultAccount = accountRegistry.getDefaultAccount();
- const selected = defaultAccount ?? accountRegistry.selectNextAccount();
+ const statsDB = getStatsDB?.() ?? undefined;
+ const selected = defaultAccount ?? accountRegistry.selectNextAccount([], statsDB ?? undefined);
if (!selected) return null;
resolvedAccountId = selected.id;
}
@@ -73,6 +83,23 @@ export function injectAccountEnv(
const account = accountRegistry.get(resolvedAccountId);
if (!account) return null;
+ // Ensure credentials exist in the account dir before spawning.
+ // If missing, attempt a best-effort sync from base ~/.claude dir.
+ const credPath = path.join(account.configDir, '.credentials.json');
+ if (!fs.existsSync(credPath)) {
+ logger.info('No .credentials.json in account dir, attempting sync from base', LOG_CONTEXT, {
+ sessionId, configDir: account.configDir,
+ });
+ // Fire-and-forget — don't block spawn on this
+ syncCredentialsFromBase(account.configDir).then((result) => {
+ if (result.success) {
+ logger.info('Auto-synced credentials from base dir', LOG_CONTEXT);
+ } else {
+ logger.warn(`Credential sync failed: ${result.error}`, LOG_CONTEXT);
+ }
+ }).catch(() => {});
+ }
+
// Inject the env var
env.CLAUDE_CONFIG_DIR = account.configDir;
diff --git a/src/main/accounts/account-throttle-handler.ts b/src/main/accounts/account-throttle-handler.ts
index 18271665d..f8808213d 100644
--- a/src/main/accounts/account-throttle-handler.ts
+++ b/src/main/accounts/account-throttle-handler.ts
@@ -87,8 +87,12 @@ export class AccountThrottleHandler {
return;
}
- // 4. Find next available account
- const nextAccount = this.accountRegistry.selectNextAccount([accountId]);
+ // 4. Find next available account (capacity-aware when stats are available)
+ const statsDb2 = this.getStatsDB();
+ const nextAccount = this.accountRegistry.selectNextAccount(
+ [accountId],
+ statsDb2.isReady() ? statsDb2 : undefined
+ );
if (!nextAccount) {
// No alternative accounts available
this.safeSend('account:throttled', {
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index 7140ac7b3..dd30d18ed 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -13,6 +13,7 @@
import { ipcMain } from 'electron';
import type { AccountRegistry } from '../../accounts/account-registry';
import type { AccountSwitcher } from '../../accounts/account-switcher';
+import type { AccountAuthRecovery } from '../../accounts/account-auth-recovery';
import type { AccountSwitchConfig, AccountSwitchEvent } from '../../../shared/account-types';
import { getStatsDB } from '../../stats';
import { logger } from '../../utils/logger';
@@ -26,6 +27,7 @@ import {
buildLoginCommand,
removeAccountDirectory,
validateRemoteAccountDir,
+ syncCredentialsFromBase,
} from '../../accounts/account-setup';
const LOG_CONTEXT = '[Accounts]';
@@ -36,13 +38,14 @@ const LOG_CONTEXT = '[Accounts]';
export interface AccountHandlerDependencies {
getAccountRegistry: () => AccountRegistry | null;
getAccountSwitcher?: () => AccountSwitcher | null;
+ getAccountAuthRecovery?: () => AccountAuthRecovery | null;
}
/**
* Register all account multiplexing IPC handlers.
*/
export function registerAccountHandlers(deps: AccountHandlerDependencies): void {
- const { getAccountRegistry, getAccountSwitcher } = deps;
+ const { getAccountRegistry, getAccountSwitcher, getAccountAuthRecovery } = deps;
/** Get the account registry or throw if not initialized */
function requireRegistry(): AccountRegistry {
@@ -241,7 +244,8 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
ipcMain.handle('accounts:select-next', async (_event, excludeIds?: string[]) => {
try {
- return requireRegistry().selectNextAccount(excludeIds);
+ const db = getStatsDB();
+ return requireRegistry().selectNextAccount(excludeIds, db.isReady() ? db : undefined);
} catch (error) {
logger.error('select next error', LOG_CONTEXT, { error: String(error) });
return null;
@@ -334,6 +338,15 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
}
});
+ ipcMain.handle('accounts:sync-credentials', async (_event, configDir: string) => {
+ try {
+ return await syncCredentialsFromBase(configDir);
+ } catch (error) {
+ logger.error('sync credentials error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
+
// --- Session Cleanup ---
ipcMain.handle('accounts:cleanup-session', async (_event, sessionId: string) => {
@@ -346,6 +359,10 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
if (switcher) {
switcher.cleanupSession(sessionId);
}
+ const authRecovery = getAccountAuthRecovery?.();
+ if (authRecovery) {
+ authRecovery.cleanupSession(sessionId);
+ }
return { success: true };
} catch (error) {
logger.error('cleanup session error', LOG_CONTEXT, { error: String(error), sessionId });
@@ -436,4 +453,25 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
return { success: false, error: String(error) };
}
});
+
+ // --- Auth Recovery ---
+
+ ipcMain.handle('accounts:trigger-auth-recovery', async (_event, sessionId: string) => {
+ try {
+ const authRecovery = getAccountAuthRecovery?.();
+ if (!authRecovery) {
+ return { success: false, error: 'Auth recovery not initialized' };
+ }
+ const registry = requireRegistry();
+ const assignment = registry.getAssignment(sessionId);
+ if (!assignment) {
+ return { success: false, error: 'No account assigned to session' };
+ }
+ const result = await authRecovery.recoverAuth(sessionId, assignment.accountId);
+ return { success: result };
+ } catch (error) {
+ logger.error('trigger auth recovery error', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+ });
}
diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts
index bbdb30351..9f06fd56d 100644
--- a/src/main/ipc/handlers/process.ts
+++ b/src/main/ipc/handlers/process.ts
@@ -4,8 +4,10 @@ import * as os from 'os';
import { ProcessManager } from '../../process-manager';
import { AgentDetector } from '../../agents';
import type { AccountSwitcher } from '../../accounts/account-switcher';
+import type { AccountAuthRecovery } from '../../accounts/account-auth-recovery';
import type { AccountRegistry } from '../../accounts/account-registry';
import { injectAccountEnv } from '../../accounts/account-env-injector';
+import { getStatsDB } from '../../stats';
import { logger } from '../../utils/logger';
import type { SafeSendFn } from '../../utils/safe-send';
import { addBreadcrumb } from '../../utils/sentry';
@@ -62,6 +64,7 @@ export interface ProcessHandlerDependencies {
getMainWindow: () => BrowserWindow | null;
sessionsStore: Store<{ sessions: any[] }>;
getAccountSwitcher?: () => AccountSwitcher | null;
+ getAccountAuthRecovery?: () => AccountAuthRecovery | null;
getAccountRegistry?: () => AccountRegistry | null;
safeSend?: SafeSendFn;
}
@@ -79,7 +82,7 @@ export interface ProcessHandlerDependencies {
* - runCommand: Execute a single command and capture output
*/
export function registerProcessHandlers(deps: ProcessHandlerDependencies): void {
- const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow, getAccountSwitcher, getAccountRegistry, safeSend: depsSafeSend } =
+ const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow, getAccountSwitcher, getAccountAuthRecovery, getAccountRegistry, safeSend: depsSafeSend } =
deps;
// Spawn a new process for a session
@@ -302,6 +305,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
registry,
config.accountId, // May be passed from renderer
depsSafeSend,
+ () => { const db = getStatsDB(); return db.isReady() ? db : null; },
);
if (assignedAccountId) {
customEnvVarsToPass = envToInject;
@@ -562,11 +566,15 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
});
const result = processManager.write(sessionId, data);
- // Record the last prompt for account switching resume
+ // Record the last prompt for account switching/auth recovery resume
const accountSwitcher = getAccountSwitcher?.();
if (accountSwitcher) {
accountSwitcher.recordLastPrompt(sessionId, data);
}
+ const authRecovery = getAccountAuthRecovery?.();
+ if (authRecovery) {
+ authRecovery.recordLastPrompt(sessionId, data);
+ }
return result;
})
From 6432d6e9ba6fc22076b701e3b3a9bbcfbbf90ef5 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 18:07:38 -0500
Subject: [PATCH 19/59] MAESTRO: feat: add all-accounts-exhausted handling,
recovery IPC, and Auto Run recovery indicator
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Subscribe to account:throttled events with noAlternatives flag to pause
Auto Runs with a specific rate_limited error when all accounts exhausted
- Add accounts:check-recovery IPC handler for manual recovery poll trigger
- Wire AccountRecoveryPoller into registerAccountHandlers dependencies
- Add recovery-specific UI in AutoRun.tsx: pulsing accent indicator with
"Waiting for account recovery — will auto-resume" and "Check Now" button
- Subscribe to account:recovery-available for auto-resume of paused runs
- Add 6 tests for accounts:check-recovery handler (all passing)
- All 19,490 tests pass, lint clean
Co-Authored-By: Claude Opus 4.6
---
.../main/ipc/handlers/accounts.test.ts | 186 ++++++++++++++++++
src/main/index.ts | 41 +++-
src/main/ipc/handlers/accounts.ts | 18 +-
src/renderer/App.tsx | 117 ++++++++++-
src/renderer/components/AutoRun.tsx | 50 ++++-
5 files changed, 407 insertions(+), 5 deletions(-)
create mode 100644 src/__tests__/main/ipc/handlers/accounts.test.ts
diff --git a/src/__tests__/main/ipc/handlers/accounts.test.ts b/src/__tests__/main/ipc/handlers/accounts.test.ts
new file mode 100644
index 000000000..f5481276d
--- /dev/null
+++ b/src/__tests__/main/ipc/handlers/accounts.test.ts
@@ -0,0 +1,186 @@
+/**
+ * Tests for the Account IPC handlers
+ *
+ * Focused on the accounts:check-recovery handler
+ * which allows manual triggering of recovery polls.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ipcMain } from 'electron';
+import { registerAccountHandlers } from '../../../../main/ipc/handlers/accounts';
+
+// Mock electron's ipcMain
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: vi.fn(),
+ removeHandler: vi.fn(),
+ },
+}));
+
+// Mock the stats module
+vi.mock('../../../../main/stats', () => ({
+ getStatsDB: vi.fn(() => ({
+ isReady: () => false,
+ getAccountUsageInWindow: vi.fn(),
+ getThrottleEvents: vi.fn().mockReturnValue([]),
+ insertThrottleEvent: vi.fn(),
+ })),
+}));
+
+// Mock the logger
+vi.mock('../../../../main/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+// Mock account-setup module
+vi.mock('../../../../main/accounts/account-setup', () => ({
+ validateBaseClaudeDir: vi.fn(),
+ discoverExistingAccounts: vi.fn(),
+ createAccountDirectory: vi.fn(),
+ validateAccountSymlinks: vi.fn(),
+ repairAccountSymlinks: vi.fn(),
+ readAccountEmail: vi.fn(),
+ buildLoginCommand: vi.fn(),
+ removeAccountDirectory: vi.fn(),
+ validateRemoteAccountDir: vi.fn(),
+ syncCredentialsFromBase: vi.fn(),
+}));
+
+describe('accounts IPC handlers', () => {
+ let handlers: Map;
+ let mockRecoveryPoller: { poll: ReturnType };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ handlers = new Map();
+
+ // Capture registered handlers
+ vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => {
+ handlers.set(channel, handler);
+ return undefined as any;
+ });
+
+ mockRecoveryPoller = {
+ poll: vi.fn().mockReturnValue(['account-1', 'account-2']),
+ };
+
+ registerAccountHandlers({
+ getAccountRegistry: () => ({
+ getAll: vi.fn().mockReturnValue([]),
+ get: vi.fn(),
+ add: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ setStatus: vi.fn(),
+ getDefaultAccount: vi.fn(),
+ selectNextAccount: vi.fn(),
+ getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }),
+ updateSwitchConfig: vi.fn(),
+ assignToSession: vi.fn(),
+ getAssignment: vi.fn(),
+ getAllAssignments: vi.fn().mockReturnValue([]),
+ removeAssignment: vi.fn(),
+ reconcileAssignments: vi.fn().mockReturnValue(0),
+ } as any),
+ getRecoveryPoller: () => mockRecoveryPoller as any,
+ });
+ });
+
+ describe('accounts:check-recovery', () => {
+ it('registers the handler', () => {
+ expect(handlers.has('accounts:check-recovery')).toBe(true);
+ });
+
+ it('returns recovered account IDs from poller', async () => {
+ const handler = handlers.get('accounts:check-recovery')!;
+ const result = await handler({});
+
+ expect(mockRecoveryPoller.poll).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ recovered: ['account-1', 'account-2'] });
+ });
+
+ it('returns empty array when no accounts recovered', async () => {
+ mockRecoveryPoller.poll.mockReturnValue([]);
+ const handler = handlers.get('accounts:check-recovery')!;
+ const result = await handler({});
+
+ expect(result).toEqual({ recovered: [] });
+ });
+
+ it('returns empty array when poller is not available', async () => {
+ // Re-register without poller
+ handlers.clear();
+ registerAccountHandlers({
+ getAccountRegistry: () => ({
+ getAll: vi.fn().mockReturnValue([]),
+ get: vi.fn(),
+ add: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ setStatus: vi.fn(),
+ getDefaultAccount: vi.fn(),
+ selectNextAccount: vi.fn(),
+ getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }),
+ updateSwitchConfig: vi.fn(),
+ assignToSession: vi.fn(),
+ getAssignment: vi.fn(),
+ getAllAssignments: vi.fn().mockReturnValue([]),
+ removeAssignment: vi.fn(),
+ reconcileAssignments: vi.fn().mockReturnValue(0),
+ } as any),
+ // No getRecoveryPoller provided
+ });
+
+ const handler = handlers.get('accounts:check-recovery')!;
+ const result = await handler({});
+
+ expect(result).toEqual({ recovered: [] });
+ });
+
+ it('returns empty array when getRecoveryPoller returns null', async () => {
+ // Re-register with poller returning null
+ handlers.clear();
+ registerAccountHandlers({
+ getAccountRegistry: () => ({
+ getAll: vi.fn().mockReturnValue([]),
+ get: vi.fn(),
+ add: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ setStatus: vi.fn(),
+ getDefaultAccount: vi.fn(),
+ selectNextAccount: vi.fn(),
+ getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }),
+ updateSwitchConfig: vi.fn(),
+ assignToSession: vi.fn(),
+ getAssignment: vi.fn(),
+ getAllAssignments: vi.fn().mockReturnValue([]),
+ removeAssignment: vi.fn(),
+ reconcileAssignments: vi.fn().mockReturnValue(0),
+ } as any),
+ getRecoveryPoller: () => null,
+ });
+
+ const handler = handlers.get('accounts:check-recovery')!;
+ const result = await handler({});
+
+ expect(result).toEqual({ recovered: [] });
+ });
+
+ it('handles errors gracefully', async () => {
+ mockRecoveryPoller.poll.mockImplementation(() => {
+ throw new Error('Poll failed');
+ });
+
+ const handler = handlers.get('accounts:check-recovery')!;
+ const result = await handler({});
+
+ expect(result).toEqual({ recovered: [] });
+ });
+ });
+});
diff --git a/src/main/index.ts b/src/main/index.ts
index 255e7fc7d..e62b31271 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -60,6 +60,8 @@ import {
import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats';
import { AccountRegistry } from './accounts/account-registry';
import { AccountThrottleHandler } from './accounts/account-throttle-handler';
+import { AccountAuthRecovery } from './accounts/account-auth-recovery';
+import { AccountRecoveryPoller } from './accounts/account-recovery-poller';
import { getAccountStore } from './stores';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import {
@@ -230,6 +232,8 @@ let webServer: WebServer | null = null;
let agentDetector: AgentDetector | null = null;
let accountRegistry: AccountRegistry | null = null;
let accountThrottleHandler: AccountThrottleHandler | null = null;
+let accountAuthRecovery: AccountAuthRecovery | null = null;
+let accountRecoveryPoller: AccountRecoveryPoller | null = null;
// Create safeSend with dependency injection (Phase 2 refactoring)
const safeSend = createSafeSend(() => mainWindow);
@@ -349,7 +353,7 @@ app.whenReady().then(async () => {
logger.warn('Continuing without stats - usage tracking will be unavailable', 'Startup');
}
- // Initialize account registry and throttle handler for account multiplexing
+ // Initialize account registry, throttle handler, and auth recovery for account multiplexing
try {
accountRegistry = new AccountRegistry(getAccountStore());
accountThrottleHandler = new AccountThrottleHandler(
@@ -361,6 +365,32 @@ app.whenReady().then(async () => {
logger.warn('Continuing without account multiplexing', 'Startup');
}
+ // Initialize auth recovery for automatic re-login on expired tokens
+ if (accountRegistry && processManager && agentDetector) {
+ try {
+ accountAuthRecovery = new AccountAuthRecovery(
+ processManager, accountRegistry, agentDetector, safeSend
+ );
+ logger.info('Account auth recovery initialized', 'Startup');
+ } catch (error) {
+ logger.error(`Failed to initialize auth recovery: ${error}`, 'Startup');
+ }
+ }
+
+ // Initialize recovery poller for timer-based throttle recovery
+ if (accountRegistry) {
+ try {
+ accountRecoveryPoller = new AccountRecoveryPoller({
+ accountRegistry,
+ safeSend,
+ });
+ accountRecoveryPoller.start();
+ logger.info('Account recovery poller started', 'Startup');
+ } catch (error) {
+ logger.error(`Failed to initialize recovery poller: ${error}`, 'Startup');
+ }
+ }
+
// Set up IPC handlers
logger.debug('Setting up IPC handlers', 'Startup');
setupIpcHandlers();
@@ -418,6 +448,11 @@ const quitHandler = createQuitHandler({
});
quitHandler.setup();
+// Stop recovery poller on quit (must run before the quit handler's cleanup)
+app.on('before-quit', () => {
+ accountRecoveryPoller?.stop();
+});
+
// startCliActivityWatcher is now handled by cliWatcher (Phase 4 refactoring)
function setupIpcHandlers() {
@@ -478,6 +513,7 @@ function setupIpcHandlers() {
getMainWindow: () => mainWindow,
sessionsStore,
getAccountRegistry: () => accountRegistry,
+ getAccountAuthRecovery: () => accountAuthRecovery,
safeSend,
});
@@ -576,6 +612,8 @@ function setupIpcHandlers() {
// Register Account Multiplexing handlers (CRUD, assignments, usage queries)
registerAccountHandlers({
getAccountRegistry: () => accountRegistry,
+ getAccountAuthRecovery: () => accountAuthRecovery,
+ getRecoveryPoller: () => accountRecoveryPoller,
});
// Register Document Graph handlers for file watching
@@ -716,6 +754,7 @@ function setupProcessListeners() {
getStatsDB,
getAccountRegistry: () => accountRegistry,
getThrottleHandler: () => accountThrottleHandler,
+ getAuthRecovery: () => accountAuthRecovery,
debugLog,
patterns: {
REGEX_MODERATOR_SESSION,
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index dd30d18ed..8a02009df 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -14,6 +14,7 @@ import { ipcMain } from 'electron';
import type { AccountRegistry } from '../../accounts/account-registry';
import type { AccountSwitcher } from '../../accounts/account-switcher';
import type { AccountAuthRecovery } from '../../accounts/account-auth-recovery';
+import type { AccountRecoveryPoller } from '../../accounts/account-recovery-poller';
import type { AccountSwitchConfig, AccountSwitchEvent } from '../../../shared/account-types';
import { getStatsDB } from '../../stats';
import { logger } from '../../utils/logger';
@@ -39,13 +40,14 @@ export interface AccountHandlerDependencies {
getAccountRegistry: () => AccountRegistry | null;
getAccountSwitcher?: () => AccountSwitcher | null;
getAccountAuthRecovery?: () => AccountAuthRecovery | null;
+ getRecoveryPoller?: () => AccountRecoveryPoller | null;
}
/**
* Register all account multiplexing IPC handlers.
*/
export function registerAccountHandlers(deps: AccountHandlerDependencies): void {
- const { getAccountRegistry, getAccountSwitcher, getAccountAuthRecovery } = deps;
+ const { getAccountRegistry, getAccountSwitcher, getAccountAuthRecovery, getRecoveryPoller } = deps;
/** Get the account registry or throw if not initialized */
function requireRegistry(): AccountRegistry {
@@ -474,4 +476,18 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
return { success: false, error: String(error) };
}
});
+
+ // --- Recovery Poller ---
+
+ ipcMain.handle('accounts:check-recovery', async () => {
+ try {
+ const poller = getRecoveryPoller?.();
+ if (!poller) return { recovered: [] };
+ const recovered = poller.poll();
+ return { recovered };
+ } catch (error) {
+ logger.error('check recovery error', LOG_CONTEXT, { error: String(error) });
+ return { recovered: [] };
+ }
+ });
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index c1de4c35c..ae7cf85f2 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -2153,6 +2153,7 @@ function MaestroConsoleInner() {
| null
>(null);
const getBatchStateRef = useRef<((sessionId: string) => BatchRunState) | null>(null);
+ const resumeAfterErrorRef = useRef<((sessionId: string) => void) | null>(null);
// Note: thinkingChunkBufferRef and thinkingChunkRafIdRef moved into useAgentListeners hook
@@ -2207,6 +2208,81 @@ function MaestroConsoleInner() {
};
}, []);
+ // Subscribe to account recovery events for auto-resume of paused Auto Runs
+ useEffect(() => {
+ const unsubRecovery = window.maestro.accounts.onRecoveryAvailable((data) => {
+ addToastRef.current({
+ type: 'success',
+ title: 'Account Recovered',
+ message: data.recoveredCount === 1
+ ? 'Account is available again'
+ : `${data.recoveredCount} accounts are available again`,
+ duration: 8_000,
+ });
+
+ // Auto-resume any Auto Runs that are paused due to rate limiting
+ const currentSessions = sessionsRef.current;
+ for (const session of currentSessions) {
+ const batchState = getBatchStateRef.current?.(session.id);
+ if (!batchState?.isRunning || batchState.processingState !== 'PAUSED_ERROR') continue;
+ if (!batchState.error) continue;
+
+ // Check if the pause was due to rate limiting
+ const isRateLimitPause =
+ batchState.error.type === 'rate_limited' ||
+ (batchState.error.message?.includes('rate') ?? false) ||
+ (batchState.error.message?.includes('throttle') ?? false) ||
+ (batchState.error.message?.includes('All accounts') ?? false);
+
+ if (isRateLimitPause) {
+ const recoveredForThis = data.recoveredAccountIds.includes(session.accountId || '');
+ if (recoveredForThis || data.recoveredAccountIds.length > 0) {
+ resumeAfterErrorRef.current?.(session.id);
+
+ addToastRef.current({
+ type: 'info',
+ title: 'Auto Run Resuming',
+ message: 'Resuming after account recovery',
+ duration: 5_000,
+ });
+ }
+ }
+ }
+ });
+
+ return () => unsubRecovery();
+ }, []);
+
+ // Subscribe to all-accounts-exhausted throttle events for Auto Run pause
+ useEffect(() => {
+ const unsubThrottled = window.maestro.accounts.onThrottled((data) => {
+ if (!data.noAlternatives) return; // Only handle the exhausted case
+
+ const sessionId = data.sessionId as string;
+ if (!sessionId) return;
+
+ // Check if this session has an active Auto Run
+ const batchState = getBatchStateRef.current?.(sessionId);
+ if (!batchState?.isRunning || batchState.errorPaused) return;
+
+ // Pause the batch with a specific rate_limited error so recovery can auto-resolve it
+ pauseBatchOnErrorRef.current?.(
+ sessionId,
+ {
+ type: 'rate_limited',
+ message: 'All accounts have been rate-limited. Waiting for automatic recovery...',
+ recoverable: true,
+ agentId: (data.agentId as string) || 'claude-code',
+ timestamp: Date.now(),
+ },
+ batchState.currentDocumentIndex,
+ 'Waiting for account recovery'
+ );
+ });
+
+ return () => unsubThrottled();
+ }, []);
+
// Subscribe to account assignment events (update session state when main process assigns an account)
useEffect(() => {
const unsubAssigned = window.maestro.accounts.onAssigned((data) => {
@@ -5604,6 +5680,7 @@ You are taking over this conversation. Based on the context above, provide a bri
// These are used by the agent error handler which runs in a useEffect with empty deps
pauseBatchOnErrorRef.current = pauseBatchOnError;
getBatchStateRef.current = getBatchState;
+ resumeAfterErrorRef.current = resumeAfterError;
// Get batch state for the current session - used for locking the AutoRun editor
// This is session-specific so users can edit docs in other sessions while one runs
@@ -7384,8 +7461,10 @@ You are taking over this conversation. Based on the context above, provide a bri
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
- }
+ },
+ accountId?: string,
) => {
+ // Update session fields immediately
setSessions((prev) =>
prev.map((s) => {
if (s.id !== sessionId) return s;
@@ -7399,11 +7478,45 @@ You are taking over this conversation. Based on the context above, provide a bri
customModel,
customContextWindow,
sessionSshRemoteConfig,
+ ...(accountId !== undefined ? { accountId } : {}),
};
})
);
+
+ // Handle account change: resolve name immediately, then trigger switch/assign
+ if (accountId) {
+ const currentSession = sessionsRef.current.find(s => s.id === sessionId);
+ const fromAccountId = currentSession?.accountId;
+
+ // Resolve account name and update session right away
+ window.maestro.accounts.list().then((accounts: any[]) => {
+ const account = accounts.find((a: any) => a.id === accountId);
+ if (account) {
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ return { ...s, accountId, accountName: account.name };
+ })
+ );
+ }
+ }).catch(() => {});
+
+ if (fromAccountId && fromAccountId !== accountId) {
+ // Full switch: kills running process, reassigns, respawns with new CLAUDE_CONFIG_DIR
+ window.maestro.accounts.executeSwitch({
+ sessionId,
+ fromAccountId,
+ toAccountId: accountId,
+ reason: 'manual',
+ automatic: false,
+ }).catch((err: any) => console.error('Failed to execute account switch:', err));
+ } else {
+ // First assignment or same account — just update registry
+ window.maestro.accounts.assign(sessionId, accountId).catch(() => {});
+ }
+ }
},
- []
+ [sessionsRef]
);
const handleRenameTab = useCallback(
diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx
index 8105aab1b..bef7a832a 100644
--- a/src/renderer/components/AutoRun.tsx
+++ b/src/renderer/components/AutoRun.tsx
@@ -1808,8 +1808,56 @@ const AutoRunInner = forwardRef(function AutoRunInn
)}
+ {/* Recovery Indicator - shown when all accounts are rate-limited and waiting for recovery */}
+ {isErrorPaused && batchError && batchError.type === 'rate_limited' && batchError.message?.includes('All accounts') && (
+
+
+
+
+
+
+
+ Waiting for account recovery — will auto-resume
+
+ window.maestro.accounts.checkRecovery()}
+ className="text-xs underline opacity-70 hover:opacity-100 transition-opacity ml-auto flex-shrink-0"
+ style={{ color: theme.colors.accent }}
+ >
+ Check Now
+
+ {onAbortBatchOnError && (
+
+
+ Abort
+
+ )}
+
+
+ )}
+
{/* Error Banner (Phase 5.10) - shown when batch is paused due to agent error */}
- {isErrorPaused && batchError && (
+ {isErrorPaused && batchError && !(batchError.type === 'rate_limited' && batchError.message?.includes('All accounts')) && (
Date: Sun, 15 Feb 2026 18:36:39 -0500
Subject: [PATCH 20/59] MAESTRO: feat: rename "Accounts" to "Virtuosos" across
all UX surfaces and promote to own modal
Rename all user-facing "Accounts" terminology to "Virtuosos" with "AI Account Providers"
subtitle. Move Virtuosos panel from Settings tab to its own top-level modal accessible from
the hamburger menu. Internal code (variable names, IPC channels, interfaces) unchanged.
Co-Authored-By: Claude Opus 4.6
---
.../renderer/components/SessionList.test.tsx | 1 +
.../components/UsageDashboardModal.test.tsx | 8 +-
src/renderer/App.tsx | 37 +++--
src/renderer/components/AccountSelector.tsx | 34 ++---
.../components/AccountSwitchModal.tsx | 26 ++--
src/renderer/components/AccountsPanel.tsx | 131 ++++++++++++++++--
src/renderer/components/AutoRun.tsx | 6 +-
src/renderer/components/InputArea.tsx | 3 +
src/renderer/components/ProcessMonitor.tsx | 2 +-
src/renderer/components/SessionItem.tsx | 10 +-
src/renderer/components/SessionList.tsx | 24 ++++
src/renderer/components/SettingsModal.tsx | 25 +---
.../UsageDashboard/AccountUsageDashboard.tsx | 12 +-
.../UsageDashboard/UsageDashboardModal.tsx | 2 +-
src/renderer/components/VirtuososModal.tsx | 42 ++++++
src/renderer/constants/modalPriorities.ts | 3 +
.../hooks/props/useSessionListProps.ts | 3 +
src/renderer/stores/modalStore.ts | 12 +-
src/renderer/types/index.ts | 2 +-
19 files changed, 287 insertions(+), 96 deletions(-)
create mode 100644 src/renderer/components/VirtuososModal.tsx
diff --git a/src/__tests__/renderer/components/SessionList.test.tsx b/src/__tests__/renderer/components/SessionList.test.tsx
index c3c49f5fd..4c8ec49a4 100644
--- a/src/__tests__/renderer/components/SessionList.test.tsx
+++ b/src/__tests__/renderer/components/SessionList.test.tsx
@@ -62,6 +62,7 @@ vi.mock('lucide-react', () => ({
Server: () => ,
Music: () => ,
Command: () => ,
+ Users: () => ,
}));
// Mock gitService
diff --git a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
index 067716147..04372974f 100644
--- a/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
+++ b/src/__tests__/renderer/components/UsageDashboardModal.test.tsx
@@ -232,7 +232,7 @@ describe('UsageDashboardModal', () => {
expect(tabs[1]).toHaveTextContent('Agents');
expect(tabs[2]).toHaveTextContent('Activity');
expect(tabs[3]).toHaveTextContent('Auto Run');
- expect(tabs[4]).toHaveTextContent('Accounts');
+ expect(tabs[4]).toHaveTextContent('Virtuosos');
});
});
@@ -1623,12 +1623,12 @@ describe('UsageDashboardModal', () => {
const tablist = screen.getByTestId('view-mode-tabs');
- // Press ArrowLeft while on first tab - should wrap to last tab (Accounts)
+ // Press ArrowLeft while on first tab - should wrap to last tab (Virtuosos)
fireEvent.keyDown(tablist, { key: 'ArrowLeft' });
await waitFor(() => {
const tabs = screen.getAllByRole('tab');
- expect(tabs[4]).toHaveAttribute('aria-selected', 'true'); // Accounts tab
+ expect(tabs[4]).toHaveAttribute('aria-selected', 'true'); // Virtuosos tab
expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
});
});
@@ -1642,7 +1642,7 @@ describe('UsageDashboardModal', () => {
const tablist = screen.getByTestId('view-mode-tabs');
- // Navigate to last tab (Accounts)
+ // Navigate to last tab (Virtuosos)
fireEvent.keyDown(tablist, { key: 'ArrowLeft' }); // Wraps to last
await waitFor(() => {
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index ae7cf85f2..1cdb8e5d5 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -45,6 +45,7 @@ import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges';
import { EmptyStateView } from './components/EmptyStateView';
import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal';
import { AccountSwitchModal } from './components/AccountSwitchModal';
+import { VirtuososModal } from './components/VirtuososModal';
// Lazy-loaded components for performance (rarely-used heavy modals)
// These are loaded on-demand when the user first opens them
@@ -408,6 +409,9 @@ function MaestroConsoleInner() {
// Director's Notes Modal
directorNotesOpen,
setDirectorNotesOpen,
+ // Virtuosos Modal
+ virtuososOpen,
+ setVirtuososOpen,
} = useModalActions();
// --- MOBILE LANDSCAPE MODE (reading-only view) ---
@@ -2187,8 +2191,8 @@ function MaestroConsoleInner() {
const unsubWarning = window.maestro.accounts.onLimitWarning((data) => {
addToastRef.current({
type: 'warning',
- title: 'Account Limit Warning',
- message: `Account ${data.accountName} is at ${Math.round(data.usagePercent)}% of its token limit`,
+ title: 'Virtuoso Limit Warning',
+ message: `Virtuoso ${data.accountName} is at ${Math.round(data.usagePercent)}% of its token limit`,
duration: 10_000,
});
});
@@ -2196,8 +2200,8 @@ function MaestroConsoleInner() {
const unsubReached = window.maestro.accounts.onLimitReached((data) => {
addToastRef.current({
type: 'error',
- title: 'Account Limit Reached',
- message: `Account ${data.accountName} has reached its token limit (${Math.round(data.usagePercent)}%)`,
+ title: 'Virtuoso Limit Reached',
+ message: `Virtuoso ${data.accountName} has reached its token limit (${Math.round(data.usagePercent)}%)`,
duration: 0, // Do NOT auto-dismiss
});
});
@@ -2213,10 +2217,10 @@ function MaestroConsoleInner() {
const unsubRecovery = window.maestro.accounts.onRecoveryAvailable((data) => {
addToastRef.current({
type: 'success',
- title: 'Account Recovered',
+ title: 'Virtuoso Recovered',
message: data.recoveredCount === 1
- ? 'Account is available again'
- : `${data.recoveredCount} accounts are available again`,
+ ? 'Virtuoso is available again'
+ : `${data.recoveredCount} virtuosos are available again`,
duration: 8_000,
});
@@ -2232,7 +2236,7 @@ function MaestroConsoleInner() {
batchState.error.type === 'rate_limited' ||
(batchState.error.message?.includes('rate') ?? false) ||
(batchState.error.message?.includes('throttle') ?? false) ||
- (batchState.error.message?.includes('All accounts') ?? false);
+ (batchState.error.message?.includes('All virtuosos') ?? false);
if (isRateLimitPause) {
const recoveredForThis = data.recoveredAccountIds.includes(session.accountId || '');
@@ -2242,7 +2246,7 @@ function MaestroConsoleInner() {
addToastRef.current({
type: 'info',
title: 'Auto Run Resuming',
- message: 'Resuming after account recovery',
+ message: 'Resuming after virtuoso recovery',
duration: 5_000,
});
}
@@ -2270,13 +2274,13 @@ function MaestroConsoleInner() {
sessionId,
{
type: 'rate_limited',
- message: 'All accounts have been rate-limited. Waiting for automatic recovery...',
+ message: 'All virtuosos have been rate-limited. Waiting for automatic recovery...',
recoverable: true,
agentId: (data.agentId as string) || 'claude-code',
timestamp: Date.now(),
},
batchState.currentDocumentIndex,
- 'Waiting for account recovery'
+ 'Waiting for virtuoso recovery'
);
});
@@ -11800,6 +11804,7 @@ You are taking over this conversation. Based on the context above, provide a bri
setDuplicatingSessionId,
setGroupChatsExpanded,
setQuickActionOpen,
+ setVirtuososOpen,
// Handlers
toggleGlobalLive,
@@ -12758,12 +12763,18 @@ You are taking over this conversation. Based on the context above, provide a bri
}}
onViewDashboard={() => {
setSwitchPromptData(null);
- setSettingsModalOpen(true);
- setSettingsTab('accounts');
+ setVirtuososOpen(true);
}}
/>
)}
+ {/* Virtuosos Modal */}
+ setVirtuososOpen(false)}
+ theme={theme}
+ />
+
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
{sessions.length === 0 && !isMobileLandscape ? (
void;
onManageAccounts?: () => void;
compact?: boolean;
@@ -39,6 +40,7 @@ export function AccountSelector({
theme,
sessionId: _sessionId,
currentAccountId,
+ currentAccountName,
onSwitchAccount,
onManageAccounts,
compact = false,
@@ -47,9 +49,8 @@ export function AccountSelector({
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
- // Fetch accounts when dropdown opens
+ // Fetch accounts on mount and when dropdown opens (refresh)
useEffect(() => {
- if (!isOpen) return;
let cancelled = false;
(async () => {
try {
@@ -60,7 +61,7 @@ export function AccountSelector({
}
})();
return () => { cancelled = true; };
- }, [isOpen]);
+ }, [isOpen, currentAccountId]);
// Close dropdown on outside click
useEffect(() => {
@@ -88,7 +89,7 @@ export function AccountSelector({
}, [isOpen]);
const currentAccount = accounts.find((a) => a.id === currentAccountId);
- const displayName = currentAccount?.name ?? currentAccount?.email ?? 'No Account';
+ const displayName = currentAccount?.name ?? currentAccount?.email ?? currentAccountName ?? 'No Virtuoso';
const handleSelect = useCallback(
(accountId: string) => {
@@ -107,20 +108,21 @@ export function AccountSelector({
setIsOpen((v) => !v)}
- className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full cursor-pointer transition-all opacity-50 hover:opacity-100"
+ className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full cursor-pointer transition-all hover:brightness-125"
style={{
- color: currentAccountId ? theme.colors.accent : theme.colors.textDim,
- backgroundColor: currentAccountId ? `${theme.colors.accent}15` : 'transparent',
+ color: currentAccountId ? theme.colors.textMain : theme.colors.textDim,
+ backgroundColor: currentAccountId ? `${theme.colors.accent}20` : `${theme.colors.border}30`,
border: currentAccountId
- ? `1px solid ${theme.colors.accent}40`
- : '1px solid transparent',
+ ? `1px solid ${theme.colors.accent}50`
+ : `1px solid ${theme.colors.border}60`,
}}
- title={currentAccountId ? `Account: ${displayName}` : 'Select account'}
+ title={currentAccountId ? `Virtuoso: ${displayName}` : 'Select virtuoso'}
>
-
- {currentAccountId && (
- {displayName.split('@')[0]}
- )}
+
+
+ {currentAccountId ? displayName.split('@')[0] : 'No Virtuoso'}
+
+
) : (
{accounts.length === 0 && (
- No accounts configured
+ No virtuosos configured
)}
{accounts.map((account) => {
@@ -234,7 +236,7 @@ export function AccountSelector({
style={{ color: theme.colors.textDim }}
>
- Manage Accounts
+ Manage Virtuosos
)}
diff --git a/src/renderer/components/AccountSwitchModal.tsx b/src/renderer/components/AccountSwitchModal.tsx
index 0a7a713b0..e1e343f0c 100644
--- a/src/renderer/components/AccountSwitchModal.tsx
+++ b/src/renderer/components/AccountSwitchModal.tsx
@@ -34,29 +34,29 @@ export interface AccountSwitchModalProps {
function getReasonHeader(reason: string): string {
switch (reason) {
case 'throttled':
- return 'Account Throttled';
+ return 'Virtuoso Throttled';
case 'limit-approaching':
case 'limit-reached':
- return 'Account Limit Reached';
+ return 'Virtuoso Limit Reached';
case 'auth-expired':
return 'Authentication Expired';
default:
- return 'Account Switch Recommended';
+ return 'Virtuoso Switch Recommended';
}
}
function getReasonDescription(reason: string, name: string, usagePercent?: number): string {
switch (reason) {
case 'throttled':
- return `Account ${name} has been rate limited`;
+ return `Virtuoso ${name} has been rate limited`;
case 'limit-approaching':
- return `Account ${name} is at ${usagePercent != null ? Math.round(usagePercent) : '?'}% of its token limit`;
+ return `Virtuoso ${name} is at ${usagePercent != null ? Math.round(usagePercent) : '?'}% of its token limit`;
case 'limit-reached':
- return `Account ${name} has reached its token limit (${usagePercent != null ? Math.round(usagePercent) : '?'}%)`;
+ return `Virtuoso ${name} has reached its token limit (${usagePercent != null ? Math.round(usagePercent) : '?'}%)`;
case 'auth-expired':
- return `Account ${name} authentication has expired`;
+ return `Virtuoso ${name} authentication has expired`;
default:
- return `Account ${name} needs to be switched`;
+ return `Virtuoso ${name} needs to be switched`;
}
}
@@ -103,10 +103,10 @@ export function AccountSwitchModal({
onClick={onViewDashboard}
className="flex items-center gap-1.5 px-3 py-2 rounded text-xs transition-colors hover:bg-white/5"
style={{ color: theme.colors.textDim }}
- title="View All Accounts"
+ title="View All Virtuosos"
>
- View All Accounts
+ View All Virtuosos
- Switch Account
+ Switch Virtuoso
}
@@ -154,7 +154,7 @@ export function AccountSwitchModal({
{getReasonDescription(reason, fromAccountName, usagePercent)}
- {/* Current account */}
+ {/* Current virtuoso */}
- Current account
+ Current virtuoso
{usagePercent != null && ` \u00B7 ${Math.round(usagePercent)}% used`}
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index 8508a43ea..83dfdf5db 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -54,6 +54,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
const [editingAccountId, setEditingAccountId] = useState(null);
const [conflictingSessions, setConflictingSessions] = useState([]);
const [loading, setLoading] = useState(true);
+ const [errorMessage, setErrorMessage] = useState(null);
const refreshAccounts = useCallback(async () => {
try {
@@ -99,6 +100,15 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
setLoading(false);
};
init();
+
+ // Auto-refresh when account status changes (e.g., marked expired during spawn)
+ const cleanupStatusChanged = window.maestro.accounts.onStatusChanged(() => {
+ refreshAccounts();
+ });
+
+ return () => {
+ cleanupStatusChanged();
+ };
}, [refreshAccounts, refreshSwitchConfig]);
const handleDiscover = async () => {
@@ -135,10 +145,11 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
const handleCreateAndLogin = async () => {
if (!newAccountName.trim()) return;
setIsCreating(true);
+ setErrorMessage(null);
try {
const result = await window.maestro.accounts.createDirectory(newAccountName.trim());
if (!result.success) {
- console.error('Failed to create directory:', result.error);
+ setErrorMessage(`Failed to create account directory: ${result.error}`);
return;
}
setCreatedConfigDir(result.configDir);
@@ -151,7 +162,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
setCreateStep('created');
}
} catch (err) {
- console.error('Failed to create account:', err);
+ setErrorMessage(`Failed to create account: ${err}`);
} finally {
setIsCreating(false);
}
@@ -241,6 +252,20 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
}
};
+ const handleSyncCredentials = async (configDir: string) => {
+ try {
+ const result = await window.maestro.accounts.syncCredentials(configDir);
+ if (result.success) {
+ setErrorMessage(null);
+ alert('Credentials synced from base ~/.claude directory.');
+ } else {
+ setErrorMessage(`Sync failed: ${result.error}`);
+ }
+ } catch (err) {
+ setErrorMessage(`Failed to sync credentials: ${err}`);
+ }
+ };
+
const statusBadge = (status: AccountProfile['status']) => {
const styles: Record<
string,
@@ -328,9 +353,14 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
className="flex items-center justify-between mb-3"
style={{ color: theme.colors.textMain }}
>
-
- Registered Accounts
-
+
+
+ Registered Virtuosos
+
+
+ AI Account Providers
+
+
- No accounts registered. Use "Discover Existing" or "Create
+ No virtuosos registered. Use "Discover Existing" or "Create
New" below.
) : (
@@ -387,6 +417,24 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
)}
{statusBadge(account.status)}
+ {account.status === 'expired' && (
+
+
+ OAuth token expired — run:{' '}
+
+ CLAUDE_CONFIG_DIR="{account.configDir}" claude login
+
+
+ )}
)}
+ {account.status === 'expired' && (
+
+ handleUpdateAccount(account.id, {
+ status: 'active',
+ })
+ }
+ className="px-2 py-1 rounded text-xs font-bold transition-colors hover:bg-white/10"
+ title="Mark as active after re-login"
+ style={{
+ color: theme.colors.success,
+ border: `1px solid ${theme.colors.success}`,
+ }}
+ >
+ Reactivate
+
+ )}
{!account.isDefault && (
handleSetDefault(account.id)}
@@ -535,7 +600,20 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
-
+
+
+ handleSyncCredentials(account.configDir)
+ }
+ className="flex items-center gap-1 px-2 py-1.5 rounded text-xs hover:bg-white/10 transition-colors"
+ style={{
+ color: theme.colors.accent,
+ border: `1px solid ${theme.colors.accent}`,
+ }}
+ >
+
+ Sync Auth
+
handleValidateSymlinks(account.configDir)
@@ -571,13 +649,13 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
)}
- {/* Add Account Section */}
+ {/* Add Virtuoso Section */}
- Add Account
+ Add Virtuoso
@@ -662,6 +740,37 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
)}
+ {/* Error message */}
+ {errorMessage && (
+
+
+
+
+ {errorMessage}
+
+
setErrorMessage(null)}
+ className="text-xs mt-1 underline"
+ style={{ color: theme.colors.textDim }}
+ >
+ Dismiss
+
+
+
+ )}
+
{/* Create new account */}
- Create New Account
+ Create New Virtuoso
{createStep === 'idle' && (
@@ -683,7 +792,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
type="text"
value={newAccountName}
onChange={(e) => setNewAccountName(e.target.value)}
- placeholder="Account name (e.g., work, personal)"
+ placeholder="Virtuoso name (e.g., work, personal)"
className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
style={{
borderColor: theme.colors.border,
diff --git a/src/renderer/components/AutoRun.tsx b/src/renderer/components/AutoRun.tsx
index bef7a832a..818c42a88 100644
--- a/src/renderer/components/AutoRun.tsx
+++ b/src/renderer/components/AutoRun.tsx
@@ -1809,7 +1809,7 @@ const AutoRunInner = forwardRef
(function AutoRunInn
)}
{/* Recovery Indicator - shown when all accounts are rate-limited and waiting for recovery */}
- {isErrorPaused && batchError && batchError.type === 'rate_limited' && batchError.message?.includes('All accounts') && (
+ {isErrorPaused && batchError && batchError.type === 'rate_limited' && batchError.message?.includes('All virtuosos') && (
(function AutoRunInn
/>
- Waiting for account recovery — will auto-resume
+ Waiting for virtuoso recovery — will auto-resume
window.maestro.accounts.checkRecovery()}
@@ -1857,7 +1857,7 @@ const AutoRunInner = forwardRef(function AutoRunInn
)}
{/* Error Banner (Phase 5.10) - shown when batch is paused due to agent error */}
- {isErrorPaused && batchError && !(batchError.type === 'rate_limited' && batchError.message?.includes('All accounts')) && (
+ {isErrorPaused && batchError && !(batchError.type === 'rate_limited' && batchError.message?.includes('All virtuosos')) && (
{
const currentAccountId = session.accountId;
if (currentAccountId && currentAccountId !== toAccountId) {
@@ -1018,6 +1020,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
await window.maestro.accounts.assign(session.id, toAccountId);
}
}}
+ onManageAccounts={() => getModalActions().setVirtuososOpen(true)}
compact
/>
)}
diff --git a/src/renderer/components/ProcessMonitor.tsx b/src/renderer/components/ProcessMonitor.tsx
index d0b964305..c41392c51 100644
--- a/src/renderer/components/ProcessMonitor.tsx
+++ b/src/renderer/components/ProcessMonitor.tsx
@@ -1442,7 +1442,7 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
className="text-xs font-medium uppercase tracking-wide"
style={{ color: theme.colors.textDim }}
>
- Account
+ Virtuoso
diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx
index 8d1c2229c..414a99b2a 100644
--- a/src/renderer/components/SessionItem.tsx
+++ b/src/renderer/components/SessionItem.tsx
@@ -181,14 +181,12 @@ export const SessionItem = memo(function SessionItem({
- {session.accountName.split('@')[0]?.slice(0, 3)?.toUpperCase() || 'ACC'}
+ {session.accountName.split('@')[0]?.slice(0, 10)?.toUpperCase() || 'ACC'}
)}
{/* Group badge (only in bookmark variant when session belongs to a group) */}
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index 1c1027789..1941b15bc 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -37,6 +37,7 @@ import {
Music,
Command,
User,
+ Users,
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import type {
@@ -458,6 +459,7 @@ interface HamburgerMenuContentProps {
setAboutModalOpen: (open: boolean) => void;
setMenuOpen: (open: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
+ setVirtuososOpen: (open: boolean) => void;
}
function HamburgerMenuContent({
@@ -478,6 +480,7 @@ function HamburgerMenuContent({
setAboutModalOpen,
setMenuOpen,
setQuickActionOpen,
+ setVirtuososOpen,
}: HamburgerMenuContentProps) {
return (
@@ -621,6 +624,23 @@ function HamburgerMenuContent({
{formatShortcutKeys(shortcuts.settings.keys)}
+
{
+ setVirtuososOpen(true);
+ setMenuOpen(false);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
+ >
+
+
+
+ Virtuosos
+
+
+ AI Account Providers
+
+
+
{
setLogViewerOpen(true);
@@ -1097,6 +1117,7 @@ interface SessionListProps {
setSymphonyModalOpen: (open: boolean) => void;
setDirectorNotesOpen: (open: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
+ setVirtuososOpen: (open: boolean) => void;
toggleGroup: (groupId: string) => void;
handleDragStart: (sessionId: string) => void;
handleDragOver: (e: React.DragEvent) => void;
@@ -1221,6 +1242,7 @@ function SessionListInner(props: SessionListProps) {
setSymphonyModalOpen,
setDirectorNotesOpen,
setQuickActionOpen,
+ setVirtuososOpen,
toggleGroup,
handleDragStart,
handleDragOver,
@@ -2515,6 +2537,7 @@ function SessionListInner(props: SessionListProps) {
setAboutModalOpen={setAboutModalOpen}
setMenuOpen={setMenuOpen}
setQuickActionOpen={setQuickActionOpen}
+ setVirtuososOpen={setVirtuososOpen}
/>
)}
@@ -2557,6 +2580,7 @@ function SessionListInner(props: SessionListProps) {
setAboutModalOpen={setAboutModalOpen}
setMenuOpen={setMenuOpen}
setQuickActionOpen={setQuickActionOpen}
+ setVirtuososOpen={setVirtuososOpen}
/>
)}
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index ab2b64f1e..2676b7bb3 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -32,7 +32,6 @@ import {
PartyPopper,
Tag,
User,
- Users,
Clapperboard,
} from 'lucide-react';
import { useSettings } from '../hooks';
@@ -61,7 +60,6 @@ import { NotificationsPanel } from './NotificationsPanel';
import { SshRemotesSection } from './Settings/SshRemotesSection';
import { SshRemoteIgnoreSection } from './Settings/SshRemoteIgnoreSection';
import { AgentConfigPanel } from './shared/AgentConfigPanel';
-import { AccountsPanel } from './AccountsPanel';
import { AGENT_TILES } from './Wizard/screens/AgentSelectionScreen';
// Feature flags - set to true to enable dormant features
@@ -288,8 +286,7 @@ interface SettingsModalProps {
| 'theme'
| 'notifications'
| 'aicommands'
- | 'ssh'
- | 'accounts';
+ | 'ssh';
hasNoAgents?: boolean;
onThemeImportError?: (message: string) => void;
onThemeImportSuccess?: (message: string) => void;
@@ -337,7 +334,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
} = useSettings();
const [activeTab, setActiveTab] = useState<
- 'general' | 'display' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh' | 'accounts' | 'director-notes'
+ 'general' | 'display' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh' | 'director-notes'
>('general');
const [systemFonts, setSystemFonts] = useState([]);
const [customFonts, setCustomFonts] = useState([]);
@@ -557,11 +554,10 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
| 'notifications'
| 'aicommands'
| 'ssh'
- | 'accounts'
| 'director-notes'
> = FEATURE_FLAGS.LLM_SETTINGS
- ? ['general', 'display', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'accounts', 'director-notes']
- : ['general', 'display', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'accounts', 'director-notes'];
+ ? ['general', 'display', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'director-notes']
+ : ['general', 'display', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh', 'director-notes'];
const currentIndex = tabs.indexOf(activeTab);
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') {
@@ -1031,16 +1027,6 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
{activeTab === 'ssh' && SSH Hosts }
- setActiveTab('accounts')}
- className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'accounts' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`}
- style={{ color: activeTab === 'accounts' ? theme.colors.textMain : theme.colors.textDim }}
- tabIndex={-1}
- title="Accounts"
- >
-
- {activeTab === 'accounts' && Accounts }
-
setActiveTab('director-notes')}
className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'director-notes' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`}
@@ -2758,8 +2744,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
)}
- {activeTab === 'accounts' &&
}
-
+
{activeTab === 'director-notes' && (() => {
// Compute derived values for director-notes tab
const dnAvailableTiles = AGENT_TILES.filter((tile) => {
diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
index 7b02bfadf..6221d289d 100644
--- a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
+++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
@@ -205,7 +205,7 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
if (loading) {
return (
- Loading account usage data...
+ Loading virtuoso usage data...
);
}
@@ -213,7 +213,7 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
if (error) {
return (
-
Failed to load account data: {error}
+
Failed to load virtuoso data: {error}
- No accounts registered
+ No virtuosos registered
- Add accounts in Settings > Accounts to start tracking usage
+ Add virtuosos via the Virtuosos menu to start tracking usage
);
@@ -239,14 +239,14 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
return (
- {/* Section 1: Account Overview Cards */}
+ {/* Section 1: Virtuoso Overview Cards */}
- Account Overview
+ Virtuoso Overview
{accounts.map((account) => {
diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
index 73fc70946..95ce5cf20 100644
--- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
+++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx
@@ -134,7 +134,7 @@ const VIEW_MODE_TABS: { value: ViewMode; label: string }[] = [
{ value: 'agents', label: 'Agents' },
{ value: 'activity', label: 'Activity' },
{ value: 'autorun', label: 'Auto Run' },
- { value: 'accounts', label: 'Accounts' },
+ { value: 'accounts', label: 'Virtuosos' },
];
export function UsageDashboardModal({
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
new file mode 100644
index 000000000..e7eca780c
--- /dev/null
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -0,0 +1,42 @@
+/**
+ * VirtuososModal - Standalone modal for account (Virtuoso) management
+ *
+ * Wraps AccountsPanel in its own top-level modal, accessible from the
+ * hamburger menu. Previously, accounts were nested under Settings.
+ */
+
+import React from 'react';
+import { Users } from 'lucide-react';
+import { AccountsPanel } from './AccountsPanel';
+import { Modal } from './ui/Modal';
+import { MODAL_PRIORITIES } from '../constants/modalPriorities';
+import type { Theme } from '../types';
+
+interface VirtuososModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ theme: Theme;
+}
+
+export function VirtuososModal({ isOpen, onClose, theme }: VirtuososModalProps) {
+ if (!isOpen) return null;
+
+ return (
+
}
+ width={720}
+ closeOnBackdropClick
+ >
+
+
+ AI Account Providers
+
+
+
+
+ );
+}
diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts
index 107397cb9..3acc9626f 100644
--- a/src/renderer/constants/modalPriorities.ts
+++ b/src/renderer/constants/modalPriorities.ts
@@ -209,6 +209,9 @@ export const MODAL_PRIORITIES = {
/** SSH Remote configuration modal (above settings) */
SSH_REMOTE: 460,
+ /** Virtuosos (account management) modal */
+ VIRTUOSOS: 455,
+
/** Settings modal */
SETTINGS: 450,
diff --git a/src/renderer/hooks/props/useSessionListProps.ts b/src/renderer/hooks/props/useSessionListProps.ts
index aa406c3d1..00866bf5c 100644
--- a/src/renderer/hooks/props/useSessionListProps.ts
+++ b/src/renderer/hooks/props/useSessionListProps.ts
@@ -100,6 +100,7 @@ export interface UseSessionListPropsDeps {
setDuplicatingSessionId: (id: string | null) => void;
setGroupChatsExpanded: (expanded: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
+ setVirtuososOpen: (open: boolean) => void;
// Handlers (should be memoized with useCallback)
toggleGlobalLive: () => void;
@@ -201,6 +202,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
setSymphonyModalOpen: deps.setSymphonyModalOpen,
setDirectorNotesOpen: deps.setDirectorNotesOpen,
setQuickActionOpen: deps.setQuickActionOpen,
+ setVirtuososOpen: deps.setVirtuososOpen,
// Handlers
toggleGroup: deps.toggleGroup,
@@ -330,6 +332,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
deps.setSymphonyModalOpen,
deps.setDirectorNotesOpen,
deps.setQuickActionOpen,
+ deps.setVirtuososOpen,
deps.setGroups,
deps.setSessions,
deps.setRenameInstanceModalOpen,
diff --git a/src/renderer/stores/modalStore.ts b/src/renderer/stores/modalStore.ts
index bc606a6b6..f345529ab 100644
--- a/src/renderer/stores/modalStore.ts
+++ b/src/renderer/stores/modalStore.ts
@@ -212,7 +212,9 @@ export type ModalId =
// Platform Warnings
| 'windowsWarning'
// Director's Notes
- | 'directorNotes';
+ | 'directorNotes'
+ // Virtuosos (Account Management)
+ | 'virtuosos';
/**
* Type mapping from ModalId to its data type.
@@ -731,6 +733,10 @@ export function getModalActions() {
setDirectorNotesOpen: (open: boolean) =>
open ? openModal('directorNotes') : closeModal('directorNotes'),
+ // Virtuosos Modal
+ setVirtuososOpen: (open: boolean) =>
+ open ? openModal('virtuosos') : closeModal('virtuosos'),
+
// Lightbox refs replacement - use updateModalData instead
setLightboxIsGroupChat: (isGroupChat: boolean) => updateModalData('lightbox', { isGroupChat }),
setLightboxAllowDelete: (allowDelete: boolean) => updateModalData('lightbox', { allowDelete }),
@@ -818,6 +824,7 @@ export function useModalActions() {
const symphonyModalOpen = useModalStore(selectModalOpen('symphony'));
const windowsWarningModalOpen = useModalStore(selectModalOpen('windowsWarning'));
const directorNotesOpen = useModalStore(selectModalOpen('directorNotes'));
+ const virtuososOpen = useModalStore(selectModalOpen('virtuosos'));
// Get stable actions
const actions = getModalActions();
@@ -982,6 +989,9 @@ export function useModalActions() {
// Director's Notes Modal
directorNotesOpen,
+ // Virtuosos Modal
+ virtuososOpen,
+
// Lightbox ref replacements (now stored as data)
lightboxIsGroupChat: lightboxData?.isGroupChat ?? false,
lightboxAllowDelete: lightboxData?.allowDelete ?? false,
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index f4c1509d6..0c69ac1dd 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -51,7 +51,7 @@ import type { AgentError } from '../../shared/types';
export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'connecting' | 'error';
export type FileChangeType = 'modified' | 'added' | 'deleted';
export type RightPanelTab = 'files' | 'history' | 'autorun';
-export type SettingsTab = 'general' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'accounts';
+export type SettingsTab = 'general' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands';
// Note: ScratchPadMode was removed as part of the Scratchpad → Auto Run migration
export type FocusArea = 'sidebar' | 'main' | 'right';
export type LLMProvider = 'openrouter' | 'anthropic' | 'ollama';
From ea5745ceef34f0d3932fcbaa902a93bec5732dc1 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 19:20:03 -0500
Subject: [PATCH 21/59] MAESTRO: feat: add inline usage metrics to Virtuosos
panel, AccountSelector, and SessionItem badge
Create shared useAccountUsage hook with real-time IPC subscription, derived
metrics (burn rate, time-to-limit), and adaptive countdown (30s default,
5s when any account is within 5 minutes of window reset).
- AccountsPanel: usage bar, token count, cost, queries, reset timer, burn rate per account
- AccountSelector: fix hardcoded 0% usage bar with live data and compact stats
- SessionItem: enhance badge tooltip to show usage percentage
- Add accounts mock to test setup for complete IPC coverage
- 17 new tests covering hook, formatTimeRemaining, and formatTokenCount
Co-Authored-By: Claude Opus 4.6
---
.../renderer/hooks/useAccountUsage.test.ts | 243 ++++++++++++++++++
src/__tests__/setup.ts | 49 ++++
src/renderer/components/AccountSelector.tsx | 46 ++--
src/renderer/components/AccountsPanel.tsx | 80 ++++++
src/renderer/components/SessionItem.tsx | 4 +-
src/renderer/components/SessionList.tsx | 6 +
src/renderer/hooks/useAccountUsage.ts | 183 +++++++++++++
7 files changed, 595 insertions(+), 16 deletions(-)
create mode 100644 src/__tests__/renderer/hooks/useAccountUsage.test.ts
create mode 100644 src/renderer/hooks/useAccountUsage.ts
diff --git a/src/__tests__/renderer/hooks/useAccountUsage.test.ts b/src/__tests__/renderer/hooks/useAccountUsage.test.ts
new file mode 100644
index 000000000..ad7c20a82
--- /dev/null
+++ b/src/__tests__/renderer/hooks/useAccountUsage.test.ts
@@ -0,0 +1,243 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../../../renderer/hooks/useAccountUsage';
+
+describe('useAccountUsage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns loading true initially and false after fetch', async () => {
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({});
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ expect(result.current.loading).toBe(true);
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+ });
+
+ it('fetches usage data on mount and calculates derived metrics', async () => {
+ const now = Date.now();
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({
+ 'acc-1': {
+ totalTokens: 142000,
+ account: { tokenLimitPerWindow: 220000, status: 'active' },
+ usagePercent: 64.5,
+ costUsd: 3.47,
+ queryCount: 28,
+ windowStart: now - 2 * 60 * 60 * 1000, // 2 hours ago
+ windowEnd: now + 3 * 60 * 60 * 1000, // 3 hours from now
+ },
+ });
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const metrics = result.current.metrics['acc-1'];
+ expect(metrics).toBeDefined();
+ expect(metrics.accountId).toBe('acc-1');
+ expect(metrics.totalTokens).toBe(142000);
+ expect(metrics.limitTokens).toBe(220000);
+ expect(metrics.usagePercent).toBe(64.5);
+ expect(metrics.costUsd).toBe(3.47);
+ expect(metrics.queryCount).toBe(28);
+ expect(metrics.status).toBe('active');
+ expect(metrics.burnRatePerHour).toBeGreaterThan(0);
+ expect(metrics.timeRemainingMs).toBeGreaterThan(0);
+ expect(metrics.estimatedTimeToLimitMs).toBeGreaterThan(0);
+ });
+
+ it('handles accounts with no limit configured', async () => {
+ const now = Date.now();
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({
+ 'acc-no-limit': {
+ totalTokens: 50000,
+ account: { tokenLimitPerWindow: 0, status: 'active' },
+ usagePercent: null,
+ costUsd: 1.23,
+ queryCount: 10,
+ windowStart: now - 60 * 60 * 1000,
+ windowEnd: now + 4 * 60 * 60 * 1000,
+ },
+ });
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const metrics = result.current.metrics['acc-no-limit'];
+ expect(metrics.usagePercent).toBeNull();
+ expect(metrics.limitTokens).toBe(0);
+ expect(metrics.estimatedTimeToLimitMs).toBeNull();
+ });
+
+ it('handles zero tokens used', async () => {
+ const now = Date.now();
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({
+ 'acc-zero': {
+ totalTokens: 0,
+ account: { tokenLimitPerWindow: 220000, status: 'active' },
+ usagePercent: 0,
+ costUsd: 0,
+ queryCount: 0,
+ windowStart: now - 30 * 60 * 1000,
+ windowEnd: now + 4.5 * 60 * 60 * 1000,
+ },
+ });
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ const metrics = result.current.metrics['acc-zero'];
+ expect(metrics.totalTokens).toBe(0);
+ expect(metrics.burnRatePerHour).toBe(0);
+ // With 0 burn rate and limit configured, estimatedTimeToLimitMs should be null
+ expect(metrics.estimatedTimeToLimitMs).toBeNull();
+ });
+
+ it('subscribes to real-time usage updates', async () => {
+ const now = Date.now();
+ let capturedHandler: ((data: any) => void) | null = null;
+
+ vi.mocked(window.maestro.accounts.onUsageUpdate).mockImplementation((handler) => {
+ capturedHandler = handler;
+ return () => {};
+ });
+
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({});
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(capturedHandler).not.toBeNull();
+
+ // Simulate a real-time update
+ act(() => {
+ capturedHandler!({
+ accountId: 'acc-rt',
+ totalTokens: 5000,
+ limitTokens: 100000,
+ usagePercent: 5,
+ costUsd: 0.15,
+ queryCount: 3,
+ windowStart: now - 30 * 60 * 1000,
+ windowEnd: now + 4.5 * 60 * 60 * 1000,
+ });
+ });
+
+ expect(result.current.metrics['acc-rt']).toBeDefined();
+ expect(result.current.metrics['acc-rt'].totalTokens).toBe(5000);
+ expect(result.current.metrics['acc-rt'].usagePercent).toBe(5);
+ });
+
+ it('cleans up subscription on unmount', async () => {
+ const cleanup = vi.fn();
+ vi.mocked(window.maestro.accounts.onUsageUpdate).mockReturnValue(cleanup);
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({});
+
+ const { unmount } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(window.maestro.accounts.getAllUsage).toHaveBeenCalled();
+ });
+
+ unmount();
+ expect(cleanup).toHaveBeenCalled();
+ });
+
+ it('handles fetch error gracefully', async () => {
+ vi.mocked(window.maestro.accounts.getAllUsage).mockRejectedValue(new Error('IPC error'));
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(result.current.metrics).toEqual({});
+ });
+
+ it('provides a refresh function', async () => {
+ vi.mocked(window.maestro.accounts.getAllUsage).mockResolvedValue({});
+
+ const { result } = renderHook(() => useAccountUsage());
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ expect(typeof result.current.refresh).toBe('function');
+ });
+});
+
+describe('formatTimeRemaining', () => {
+ it('returns "—" for zero or negative values', () => {
+ expect(formatTimeRemaining(0)).toBe('—');
+ expect(formatTimeRemaining(-1000)).toBe('—');
+ });
+
+ it('formats hours and minutes', () => {
+ expect(formatTimeRemaining(2 * 60 * 60 * 1000 + 34 * 60 * 1000)).toBe('2h 34m');
+ expect(formatTimeRemaining(1 * 60 * 60 * 1000)).toBe('1h 0m');
+ });
+
+ it('formats minutes only', () => {
+ expect(formatTimeRemaining(45 * 60 * 1000)).toBe('45m');
+ expect(formatTimeRemaining(5 * 60 * 1000)).toBe('5m');
+ });
+
+ it('formats sub-5-minute with seconds', () => {
+ expect(formatTimeRemaining(4 * 60 * 1000 + 32 * 1000)).toBe('4m 32s');
+ expect(formatTimeRemaining(1 * 60 * 1000 + 15 * 1000)).toBe('1m 15s');
+ });
+
+ it('returns "< 1m" for very small values', () => {
+ expect(formatTimeRemaining(30 * 1000)).toBe('< 1m');
+ expect(formatTimeRemaining(500)).toBe('< 1m');
+ });
+});
+
+describe('formatTokenCount', () => {
+ it('returns raw number for small values', () => {
+ expect(formatTokenCount(0)).toBe('0');
+ expect(formatTokenCount(856)).toBe('856');
+ expect(formatTokenCount(999)).toBe('999');
+ });
+
+ it('formats thousands with K suffix', () => {
+ expect(formatTokenCount(1000)).toBe('1.0K');
+ expect(formatTokenCount(1500)).toBe('1.5K');
+ expect(formatTokenCount(9999)).toBe('10.0K');
+ });
+
+ it('formats tens of thousands with K suffix (no decimal)', () => {
+ expect(formatTokenCount(10000)).toBe('10K');
+ expect(formatTokenCount(142000)).toBe('142K');
+ expect(formatTokenCount(999999)).toBe('1000K');
+ });
+
+ it('formats millions with M suffix', () => {
+ expect(formatTokenCount(1000000)).toBe('1.0M');
+ expect(formatTokenCount(1200000)).toBe('1.2M');
+ expect(formatTokenCount(15000000)).toBe('15.0M');
+ });
+});
diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts
index 6d623e8fe..40cfdf6ed 100644
--- a/src/__tests__/setup.ts
+++ b/src/__tests__/setup.ts
@@ -513,6 +513,55 @@ const mockMaestro = {
onUpdated: vi.fn().mockReturnValue(() => {}),
onContributionStarted: vi.fn().mockReturnValue(() => {}),
},
+ accounts: {
+ list: vi.fn().mockResolvedValue([]),
+ get: vi.fn().mockResolvedValue(null),
+ add: vi.fn().mockResolvedValue({}),
+ update: vi.fn().mockResolvedValue({}),
+ remove: vi.fn().mockResolvedValue({}),
+ setDefault: vi.fn().mockResolvedValue({}),
+ assign: vi.fn().mockResolvedValue({}),
+ getAssignment: vi.fn().mockResolvedValue(null),
+ getAllAssignments: vi.fn().mockResolvedValue([]),
+ getUsage: vi.fn().mockResolvedValue({}),
+ getAllUsage: vi.fn().mockResolvedValue({}),
+ getThrottleEvents: vi.fn().mockResolvedValue([]),
+ getSwitchConfig: vi.fn().mockResolvedValue({}),
+ updateSwitchConfig: vi.fn().mockResolvedValue({}),
+ getDefault: vi.fn().mockResolvedValue(null),
+ selectNext: vi.fn().mockResolvedValue(null),
+ validateBaseDir: vi.fn().mockResolvedValue({ valid: true, baseDir: '/home/test/.claude', errors: [] }),
+ discoverExisting: vi.fn().mockResolvedValue([]),
+ createDirectory: vi.fn().mockResolvedValue({ success: true, configDir: '/home/test/.claude-test' }),
+ validateSymlinks: vi.fn().mockResolvedValue({ valid: true, broken: [], missing: [] }),
+ repairSymlinks: vi.fn().mockResolvedValue({ repaired: [], errors: [] }),
+ readEmail: vi.fn().mockResolvedValue(null),
+ getLoginCommand: vi.fn().mockResolvedValue(null),
+ removeDirectory: vi.fn().mockResolvedValue({ success: true }),
+ validateRemoteDir: vi.fn().mockResolvedValue({ exists: true, hasAuth: true, symlinksValid: true }),
+ syncCredentials: vi.fn().mockResolvedValue({ success: true }),
+ onUsageUpdate: vi.fn().mockReturnValue(() => {}),
+ onLimitWarning: vi.fn().mockReturnValue(() => {}),
+ onLimitReached: vi.fn().mockReturnValue(() => {}),
+ onThrottled: vi.fn().mockReturnValue(() => {}),
+ onSwitchPrompt: vi.fn().mockReturnValue(() => {}),
+ onSwitchExecute: vi.fn().mockReturnValue(() => {}),
+ onStatusChanged: vi.fn().mockReturnValue(() => {}),
+ onAssigned: vi.fn().mockReturnValue(() => {}),
+ reconcileSessions: vi.fn().mockResolvedValue({ success: true, removed: 0, corrections: [] }),
+ cleanupSession: vi.fn().mockResolvedValue({ success: true }),
+ executeSwitch: vi.fn().mockResolvedValue({ success: true }),
+ onSwitchStarted: vi.fn().mockReturnValue(() => {}),
+ onSwitchRespawn: vi.fn().mockReturnValue(() => {}),
+ onSwitchCompleted: vi.fn().mockReturnValue(() => {}),
+ onSwitchFailed: vi.fn().mockReturnValue(() => {}),
+ triggerAuthRecovery: vi.fn().mockResolvedValue({ success: true }),
+ onAuthRecoveryStarted: vi.fn().mockReturnValue(() => {}),
+ onAuthRecoveryCompleted: vi.fn().mockReturnValue(() => {}),
+ onAuthRecoveryFailed: vi.fn().mockReturnValue(() => {}),
+ onRecoveryAvailable: vi.fn().mockReturnValue(() => {}),
+ checkRecovery: vi.fn().mockResolvedValue({ recovered: [] }),
+ },
app: {
onQuitConfirmationRequest: vi.fn().mockReturnValue(() => {}),
confirmQuit: vi.fn(),
diff --git a/src/renderer/components/AccountSelector.tsx b/src/renderer/components/AccountSelector.tsx
index 06fe6e049..bea83f8df 100644
--- a/src/renderer/components/AccountSelector.tsx
+++ b/src/renderer/components/AccountSelector.tsx
@@ -11,6 +11,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import { User, ChevronDown, Settings } from 'lucide-react';
import type { Theme } from '../types';
import type { AccountProfile } from '../../shared/account-types';
+import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
export interface AccountSelectorProps {
theme: Theme;
@@ -48,6 +49,7 @@ export function AccountSelector({
const [accounts, setAccounts] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
+ const { metrics: usageMetrics } = useAccountUsage();
// Fetch accounts on mount and when dropdown opens (refresh)
useEffect(() => {
@@ -188,21 +190,35 @@ export function AccountSelector({
>
{account.name || account.email}
- {/* Usage bar if limit configured */}
- {account.tokenLimitPerWindow > 0 && (
-
- )}
+ {/* Usage bar with real-time data */}
+ {(() => {
+ const usage = usageMetrics[account.id];
+ if (!usage || usage.usagePercent === null) return null;
+ return (
+
+
+
= 95
+ ? '#ef4444'
+ : usage.usagePercent >= 80
+ ? '#f59e0b'
+ : theme.colors.accent,
+ }}
+ />
+
+
+ {formatTokenCount(usage.totalTokens)} / {formatTokenCount(usage.limitTokens)}
+ {formatTimeRemaining(usage.timeRemainingMs)}
+
+
+ );
+ })()}
{/* Current indicator */}
{isCurrent && (
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index 83dfdf5db..a91f14b5f 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -15,6 +15,7 @@ import {
import type { Theme } from '../types';
import type { AccountProfile, AccountSwitchConfig } from '../../shared/account-types';
import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
+import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
interface AccountsPanelProps {
theme: Theme;
@@ -55,6 +56,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
const [conflictingSessions, setConflictingSessions] = useState
([]);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState(null);
+ const { metrics: usageMetrics } = useAccountUsage();
const refreshAccounts = useCallback(async () => {
try {
@@ -451,6 +453,84 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
+ {/* Inline usage metrics */}
+ {(() => {
+ const usage = usageMetrics[account.id];
+ if (!usage) return null;
+ return (
+
+ {/* Usage bar */}
+ {usage.usagePercent !== null && (
+
+
+
= 95
+ ? '#ef4444'
+ : usage.usagePercent >= 80
+ ? '#f59e0b'
+ : theme.colors.accent,
+ }}
+ />
+
+
+ {Math.round(usage.usagePercent)}%
+
+
+ )}
+
+ {/* Metrics grid */}
+
+
+ Tokens:
+ {formatTokenCount(usage.totalTokens)}
+ {usage.limitTokens > 0 && ` / ${formatTokenCount(usage.limitTokens)}`}
+
+
+
+ Cost:
+ ${usage.costUsd.toFixed(2)}
+
+
+
+ Queries:
+ {usage.queryCount}
+
+
+
+ Resets in:
+ {formatTimeRemaining(usage.timeRemainingMs)}
+
+
+ {usage.burnRatePerHour > 0 && (
+
+ Burn rate:
+ ~{formatTokenCount(Math.round(usage.burnRatePerHour))}/hr
+
+
+ )}
+ {usage.estimatedTimeToLimitMs !== null && (
+
+ To limit:
+ ~{formatTimeRemaining(usage.estimatedTimeToLimitMs)}
+
+
+ )}
+
+
+ );
+ })()}
diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx
index 414a99b2a..06ef6f0dc 100644
--- a/src/renderer/components/SessionItem.tsx
+++ b/src/renderer/components/SessionItem.tsx
@@ -35,6 +35,7 @@ export interface SessionItemProps {
gitFileCount?: number;
isInBatch?: boolean;
jumpNumber?: string | null; // Session jump shortcut number (1-9, 0)
+ accountUsagePercent?: number | null; // Usage % for assigned account (passed from parent to avoid N hook instances)
// Handlers
onSelect: () => void;
@@ -75,6 +76,7 @@ export const SessionItem = memo(function SessionItem({
gitFileCount,
isInBatch = false,
jumpNumber,
+ accountUsagePercent,
onSelect,
onDragStart,
onDragOver,
@@ -184,7 +186,7 @@ export const SessionItem = memo(function SessionItem({
backgroundColor: `${theme.colors.accent}25`,
color: theme.colors.accentText || theme.colors.accent,
}}
- title={`Virtuoso: ${session.accountName}`}
+ title={`Virtuoso: ${session.accountName}${accountUsagePercent != null ? ` (${Math.round(accountUsagePercent)}%)` : ''}`}
>
{session.accountName.split('@')[0]?.slice(0, 10)?.toUpperCase() || 'ACC'}
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index 1941b15bc..73ed5b438 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -55,6 +55,7 @@ import { getBadgeForTime } from '../constants/conductorBadges';
import { getStatusColor, getContextColor, formatActiveTime } from '../utils/theme';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { SessionItem } from './SessionItem';
+import { useAccountUsage } from '../hooks/useAccountUsage';
import { GroupChatList } from './GroupChatList';
import { useLiveOverlay, useClickOutside, useResizablePanel } from '../hooks';
import { useGitFileStatus } from '../contexts/GitStatusContext';
@@ -1299,6 +1300,9 @@ function SessionListInner(props: SessionListProps) {
// Derive whether any session is busy (for wand sparkle animation)
const isAnyBusy = useMemo(() => sessions.some((s) => s.state === 'busy'), [sessions]);
+ // Account usage metrics for SessionItem badge tooltips
+ const { metrics: accountUsageMetrics } = useAccountUsage();
+
const [sessionFilter, setSessionFilter] = useState('');
const { onResizeStart: onSidebarResizeStart, transitionClass: sidebarTransitionClass } = useResizablePanel({
width: leftSidebarWidthState,
@@ -1660,6 +1664,7 @@ function SessionListInner(props: SessionListProps) {
gitFileCount={getFileCount(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
+ accountUsagePercent={session.accountId ? accountUsageMetrics[session.accountId]?.usagePercent : undefined}
onSelect={selectHandlers.get(session.id)!}
onDragStart={dragStartHandlers.get(session.id)!}
onDragOver={handleDragOver}
@@ -1722,6 +1727,7 @@ function SessionListInner(props: SessionListProps) {
gitFileCount={getFileCount(child.id)}
isInBatch={activeBatchSessionIds.includes(child.id)}
jumpNumber={getSessionJumpNumber(child.id)}
+ accountUsagePercent={child.accountId ? accountUsageMetrics[child.accountId]?.usagePercent : undefined}
onSelect={selectHandlers.get(child.id)!}
onDragStart={dragStartHandlers.get(child.id)!}
onContextMenu={contextMenuHandlers.get(child.id)!}
diff --git a/src/renderer/hooks/useAccountUsage.ts b/src/renderer/hooks/useAccountUsage.ts
new file mode 100644
index 000000000..0c717e072
--- /dev/null
+++ b/src/renderer/hooks/useAccountUsage.ts
@@ -0,0 +1,183 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+
+export interface AccountUsageMetrics {
+ accountId: string;
+ totalTokens: number;
+ limitTokens: number;
+ usagePercent: number | null;
+ costUsd: number;
+ queryCount: number;
+ windowStart: number;
+ windowEnd: number;
+ timeRemainingMs: number;
+ burnRatePerHour: number;
+ estimatedTimeToLimitMs: number | null; // null if no limit or burn rate is 0
+ status: string;
+}
+
+const DEFAULT_INTERVAL_MS = 30_000;
+const URGENT_INTERVAL_MS = 5_000;
+const URGENT_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
+
+/**
+ * Hook that provides real-time per-account usage metrics.
+ * Fetches on mount, subscribes to real-time updates, and recalculates
+ * derived metrics (burn rate, time to limit) every 30 seconds.
+ * Switches to 5-second updates when any account is within 5 minutes of reset.
+ */
+export function useAccountUsage(): {
+ metrics: Record;
+ loading: boolean;
+ refresh: () => void;
+} {
+ const [metrics, setMetrics] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const intervalRef = useRef | null>(null);
+ const currentIntervalMs = useRef(DEFAULT_INTERVAL_MS);
+
+ const calculateDerivedMetrics = useCallback((raw: {
+ accountId: string;
+ totalTokens: number;
+ limitTokens: number;
+ usagePercent: number | null;
+ costUsd: number;
+ queryCount: number;
+ windowStart: number;
+ windowEnd: number;
+ status: string;
+ }): AccountUsageMetrics => {
+ const now = Date.now();
+ const timeRemainingMs = Math.max(0, raw.windowEnd - now);
+ const elapsedMs = Math.max(1, now - raw.windowStart); // avoid divide by zero
+ const elapsedHours = elapsedMs / (1000 * 60 * 60);
+
+ // Burn rate: tokens consumed per hour in this window
+ const burnRatePerHour = raw.totalTokens / elapsedHours;
+
+ // Estimated time to hit limit (null if no limit configured)
+ let estimatedTimeToLimitMs: number | null = null;
+ if (raw.limitTokens > 0 && burnRatePerHour > 0) {
+ const remainingTokens = Math.max(0, raw.limitTokens - raw.totalTokens);
+ const hoursToLimit = remainingTokens / burnRatePerHour;
+ estimatedTimeToLimitMs = hoursToLimit * 60 * 60 * 1000;
+ }
+
+ return {
+ ...raw,
+ timeRemainingMs,
+ burnRatePerHour,
+ estimatedTimeToLimitMs,
+ };
+ }, []);
+
+ const recalculate = useCallback(() => {
+ setMetrics(prev => {
+ const updated: Record = {};
+ for (const [id, m] of Object.entries(prev)) {
+ updated[id] = calculateDerivedMetrics(m);
+ }
+
+ // Adaptive interval: switch to 5s when any account is near reset
+ const hasUrgentCountdown = Object.values(updated).some(
+ m => m.timeRemainingMs > 0 && m.timeRemainingMs < URGENT_THRESHOLD_MS
+ );
+ const targetInterval = hasUrgentCountdown ? URGENT_INTERVAL_MS : DEFAULT_INTERVAL_MS;
+ if (targetInterval !== currentIntervalMs.current && intervalRef.current) {
+ clearInterval(intervalRef.current);
+ currentIntervalMs.current = targetInterval;
+ intervalRef.current = setInterval(recalculate, targetInterval);
+ }
+
+ return updated;
+ });
+ }, [calculateDerivedMetrics]);
+
+ const fetchUsage = useCallback(async () => {
+ try {
+ const allUsage = await window.maestro.accounts.getAllUsage();
+ if (!allUsage || typeof allUsage !== 'object') return;
+
+ const newMetrics: Record = {};
+ for (const [accountId, usage] of Object.entries(allUsage as Record)) {
+ newMetrics[accountId] = calculateDerivedMetrics({
+ accountId,
+ totalTokens: usage.totalTokens || 0,
+ limitTokens: usage.account?.tokenLimitPerWindow || 0,
+ usagePercent: usage.usagePercent ?? null,
+ costUsd: usage.costUsd || 0,
+ queryCount: usage.queryCount || 0,
+ windowStart: usage.windowStart || Date.now(),
+ windowEnd: usage.windowEnd || Date.now(),
+ status: usage.account?.status || 'active',
+ });
+ }
+ setMetrics(newMetrics);
+ setLoading(false);
+ } catch {
+ setLoading(false);
+ }
+ }, [calculateDerivedMetrics]);
+
+ useEffect(() => {
+ fetchUsage();
+
+ // Subscribe to real-time usage updates
+ const unsub = window.maestro.accounts.onUsageUpdate((data) => {
+ const accountId = data.accountId;
+ if (!accountId) return;
+
+ setMetrics(prev => ({
+ ...prev,
+ [accountId]: calculateDerivedMetrics({
+ accountId,
+ totalTokens: data.totalTokens || 0,
+ limitTokens: data.limitTokens || 0,
+ usagePercent: data.usagePercent ?? null,
+ costUsd: data.costUsd || 0,
+ queryCount: data.queryCount || 0,
+ windowStart: data.windowStart || Date.now(),
+ windowEnd: data.windowEnd || Date.now(),
+ status: prev[accountId]?.status || 'active',
+ }),
+ }));
+ });
+
+ // Recalculate derived metrics periodically
+ currentIntervalMs.current = DEFAULT_INTERVAL_MS;
+ intervalRef.current = setInterval(recalculate, DEFAULT_INTERVAL_MS);
+
+ return () => {
+ unsub();
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [fetchUsage, calculateDerivedMetrics, recalculate]);
+
+ return { metrics, loading, refresh: fetchUsage };
+}
+
+/**
+ * Format milliseconds into a human-readable time string.
+ * Examples: "2h 34m", "45m", "4m 32s", "< 1m", "—" (if 0 or negative)
+ */
+export function formatTimeRemaining(ms: number): string {
+ if (ms <= 0) return '—';
+ const hours = Math.floor(ms / (1000 * 60 * 60));
+ const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ if (minutes >= 5) return `${minutes}m`;
+ // Under 5 minutes: show seconds for precision
+ const seconds = Math.floor((ms % (1000 * 60)) / 1000);
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
+ return '< 1m';
+}
+
+/**
+ * Format token count with K/M suffix.
+ * Examples: "142K", "1.2M", "856"
+ */
+export function formatTokenCount(tokens: number): string {
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
+ if (tokens >= 10_000) return `${Math.round(tokens / 1_000)}K`;
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`;
+ return String(tokens);
+}
From 768141cb70b1b6850683a1ee6266ba493a4aae24 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 19:31:04 -0500
Subject: [PATCH 22/59] MAESTRO: feat: add usage analytics with P90
predictions, historical trends, and plan presets
Implements ACCT-MUX-20: per-account daily/monthly aggregation queries,
IPC handlers for historical usage, P90-weighted predictive analysis with
exponential decay, plan preset picker (Pro/Max5/Max20), collapsible
historical usage view with 7d/30d/monthly modes, throttle frequency
tracking, and window history caching for predictions.
Co-Authored-By: Claude Opus 4.6
---
.../renderer/hooks/useAccountUsage.test.ts | 111 +++++++++++-
src/__tests__/setup.ts | 3 +
src/main/ipc/handlers/accounts.ts | 37 ++++
src/main/preload/accounts.ts | 78 ++++++++
src/main/stats/account-usage.ts | 123 +++++++++++++
src/main/stats/stats-db.ts | 17 ++
.../components/AccountUsageHistory.tsx | 147 +++++++++++++++
src/renderer/components/AccountsPanel.tsx | 130 +++++++++++++-
src/renderer/global.d.ts | 10 ++
src/renderer/hooks/useAccountUsage.ts | 167 ++++++++++++++++++
10 files changed, 813 insertions(+), 10 deletions(-)
create mode 100644 src/renderer/components/AccountUsageHistory.tsx
diff --git a/src/__tests__/renderer/hooks/useAccountUsage.test.ts b/src/__tests__/renderer/hooks/useAccountUsage.test.ts
index ad7c20a82..a85ef4fb2 100644
--- a/src/__tests__/renderer/hooks/useAccountUsage.test.ts
+++ b/src/__tests__/renderer/hooks/useAccountUsage.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
-import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../../../renderer/hooks/useAccountUsage';
+import { useAccountUsage, formatTimeRemaining, formatTokenCount, calculatePrediction } from '../../../renderer/hooks/useAccountUsage';
describe('useAccountUsage', () => {
beforeEach(() => {
@@ -189,6 +189,115 @@ describe('useAccountUsage', () => {
});
});
+describe('calculatePrediction', () => {
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
+
+ it('returns empty prediction with no window history', () => {
+ const result = calculatePrediction([], 0, 100_000, FIVE_HOURS_MS);
+ expect(result.confidence).toBe('low');
+ expect(result.linearTimeToLimitMs).toBeNull();
+ expect(result.weightedTimeToLimitMs).toBeNull();
+ expect(result.p90TokensPerWindow).toBe(0);
+ expect(result.avgTokensPerWindow).toBe(0);
+ expect(result.windowsRemainingP90).toBeNull();
+ });
+
+ it('returns low confidence with fewer than 5 windows', () => {
+ const history = [
+ { totalTokens: 10_000, windowStart: 0, windowEnd: FIVE_HOURS_MS },
+ { totalTokens: 12_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS },
+ ];
+ const result = calculatePrediction(history, 5_000, 100_000, FIVE_HOURS_MS);
+ expect(result.confidence).toBe('low');
+ });
+
+ it('returns medium confidence with 5-15 windows', () => {
+ const history = Array.from({ length: 8 }, (_, i) => ({
+ totalTokens: 10_000 + i * 1000,
+ windowStart: i * FIVE_HOURS_MS,
+ windowEnd: (i + 1) * FIVE_HOURS_MS,
+ }));
+ const result = calculatePrediction(history, 5_000, 100_000, FIVE_HOURS_MS);
+ expect(result.confidence).toBe('medium');
+ });
+
+ it('returns high confidence with more than 15 windows', () => {
+ const history = Array.from({ length: 20 }, (_, i) => ({
+ totalTokens: 10_000,
+ windowStart: i * FIVE_HOURS_MS,
+ windowEnd: (i + 1) * FIVE_HOURS_MS,
+ }));
+ const result = calculatePrediction(history, 5_000, 100_000, FIVE_HOURS_MS);
+ expect(result.confidence).toBe('high');
+ });
+
+ it('calculates correct average', () => {
+ const history = [
+ { totalTokens: 10_000, windowStart: 0, windowEnd: FIVE_HOURS_MS },
+ { totalTokens: 20_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS },
+ { totalTokens: 30_000, windowStart: 2 * FIVE_HOURS_MS, windowEnd: 3 * FIVE_HOURS_MS },
+ ];
+ const result = calculatePrediction(history, 0, 100_000, FIVE_HOURS_MS);
+ expect(result.avgTokensPerWindow).toBe(20_000);
+ });
+
+ it('calculates P90 as the 90th percentile', () => {
+ const history = Array.from({ length: 10 }, (_, i) => ({
+ totalTokens: (i + 1) * 1000,
+ windowStart: i * FIVE_HOURS_MS,
+ windowEnd: (i + 1) * FIVE_HOURS_MS,
+ }));
+ const result = calculatePrediction(history, 0, 100_000, FIVE_HOURS_MS);
+ // sorted: [1K, 2K, ..., 10K], p90Index = floor(10*0.9) = 9 => 10K
+ expect(result.p90TokensPerWindow).toBe(10_000);
+ });
+
+ it('P90 prediction is more conservative than linear', () => {
+ const history = [
+ { totalTokens: 5_000, windowStart: 0, windowEnd: FIVE_HOURS_MS },
+ { totalTokens: 5_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS },
+ { totalTokens: 5_000, windowStart: 2 * FIVE_HOURS_MS, windowEnd: 3 * FIVE_HOURS_MS },
+ { totalTokens: 5_000, windowStart: 3 * FIVE_HOURS_MS, windowEnd: 4 * FIVE_HOURS_MS },
+ { totalTokens: 50_000, windowStart: 4 * FIVE_HOURS_MS, windowEnd: 5 * FIVE_HOURS_MS },
+ ];
+ const result = calculatePrediction(history, 10_000, 100_000, FIVE_HOURS_MS);
+ expect(result.windowsRemainingP90).not.toBeNull();
+ expect(result.linearTimeToLimitMs).not.toBeNull();
+ if (result.windowsRemainingP90 !== null && result.linearTimeToLimitMs !== null) {
+ const linearWindows = result.linearTimeToLimitMs / FIVE_HOURS_MS;
+ expect(result.windowsRemainingP90).toBeLessThanOrEqual(linearWindows);
+ }
+ });
+
+ it('returns null predictions when no limit is configured', () => {
+ const history = [
+ { totalTokens: 10_000, windowStart: 0, windowEnd: FIVE_HOURS_MS },
+ ];
+ const result = calculatePrediction(history, 5_000, 0, FIVE_HOURS_MS);
+ expect(result.linearTimeToLimitMs).toBeNull();
+ expect(result.weightedTimeToLimitMs).toBeNull();
+ expect(result.windowsRemainingP90).toBeNull();
+ expect(result.avgTokensPerWindow).toBe(10_000);
+ });
+
+ it('recent windows weigh more heavily in weighted average', () => {
+ const history = [
+ { totalTokens: 1_000, windowStart: 0, windowEnd: FIVE_HOURS_MS },
+ { totalTokens: 1_000, windowStart: FIVE_HOURS_MS, windowEnd: 2 * FIVE_HOURS_MS },
+ { totalTokens: 1_000, windowStart: 2 * FIVE_HOURS_MS, windowEnd: 3 * FIVE_HOURS_MS },
+ { totalTokens: 50_000, windowStart: 3 * FIVE_HOURS_MS, windowEnd: 4 * FIVE_HOURS_MS },
+ { totalTokens: 50_000, windowStart: 4 * FIVE_HOURS_MS, windowEnd: 5 * FIVE_HOURS_MS },
+ ];
+ const result = calculatePrediction(history, 10_000, 200_000, FIVE_HOURS_MS);
+ // Weighted time should be shorter than linear (recent high usage pushes prediction down)
+ expect(result.weightedTimeToLimitMs).not.toBeNull();
+ expect(result.linearTimeToLimitMs).not.toBeNull();
+ if (result.weightedTimeToLimitMs !== null && result.linearTimeToLimitMs !== null) {
+ expect(result.weightedTimeToLimitMs).toBeLessThan(result.linearTimeToLimitMs);
+ }
+ });
+});
+
describe('formatTimeRemaining', () => {
it('returns "—" for zero or negative values', () => {
expect(formatTimeRemaining(0)).toBe('—');
diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts
index 40cfdf6ed..55a7043ff 100644
--- a/src/__tests__/setup.ts
+++ b/src/__tests__/setup.ts
@@ -526,6 +526,9 @@ const mockMaestro = {
getUsage: vi.fn().mockResolvedValue({}),
getAllUsage: vi.fn().mockResolvedValue({}),
getThrottleEvents: vi.fn().mockResolvedValue([]),
+ getDailyUsage: vi.fn().mockResolvedValue([]),
+ getMonthlyUsage: vi.fn().mockResolvedValue([]),
+ getWindowHistory: vi.fn().mockResolvedValue([]),
getSwitchConfig: vi.fn().mockResolvedValue({}),
updateSwitchConfig: vi.fn().mockResolvedValue({}),
getDefault: vi.fn().mockResolvedValue(null),
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index 8a02009df..272160da5 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -212,6 +212,43 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
}
});
+ ipcMain.handle('accounts:get-daily-usage', async (_event, accountId: string, days: number = 30) => {
+ try {
+ const db = getStatsDB();
+ if (!db?.isReady()) return [];
+ const now = Date.now();
+ const sinceMs = now - days * 24 * 60 * 60 * 1000;
+ return db.getAccountDailyUsage(accountId, sinceMs, now);
+ } catch (error) {
+ logger.error('get daily usage error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
+ ipcMain.handle('accounts:get-monthly-usage', async (_event, accountId: string, months: number = 6) => {
+ try {
+ const db = getStatsDB();
+ if (!db?.isReady()) return [];
+ const now = Date.now();
+ const sinceMs = now - months * 30 * 24 * 60 * 60 * 1000;
+ return db.getAccountMonthlyUsage(accountId, sinceMs, now);
+ } catch (error) {
+ logger.error('get monthly usage error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
+ ipcMain.handle('accounts:get-window-history', async (_event, accountId: string, windowCount: number = 40) => {
+ try {
+ const db = getStatsDB();
+ if (!db?.isReady()) return [];
+ return db.getAccountWindowHistory(accountId, windowCount);
+ } catch (error) {
+ logger.error('get window history error', LOG_CONTEXT, { error: String(error) });
+ return [];
+ }
+ });
+
// --- Switch Configuration ---
ipcMain.handle('accounts:get-switch-config', async () => {
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index 135a96c5b..3788b8c57 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -91,6 +91,18 @@ export function createAccountsApi() {
getThrottleEvents: (accountId?: string, since?: number): Promise =>
ipcRenderer.invoke('accounts:get-throttle-events', accountId, since),
+ /** Get daily usage aggregation for an account */
+ getDailyUsage: (accountId: string, days?: number): Promise =>
+ ipcRenderer.invoke('accounts:get-daily-usage', accountId, days),
+
+ /** Get monthly usage aggregation for an account */
+ getMonthlyUsage: (accountId: string, months?: number): Promise =>
+ ipcRenderer.invoke('accounts:get-monthly-usage', accountId, months),
+
+ /** Get billing window history for an account (for P90 predictions) */
+ getWindowHistory: (accountId: string, windowCount?: number): Promise =>
+ ipcRenderer.invoke('accounts:get-window-history', accountId, windowCount),
+
// --- Switch Configuration ---
/** Get the current account switching configuration */
@@ -147,6 +159,10 @@ export function createAccountsApi() {
validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }): Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }> =>
ipcRenderer.invoke('accounts:validate-remote-dir', params),
+ /** Sync credentials from base ~/.claude to an account directory */
+ syncCredentials: (configDir: string): Promise<{ success: boolean; error?: string }> =>
+ ipcRenderer.invoke('accounts:sync-credentials', configDir),
+
// --- Event Listeners ---
/**
@@ -318,6 +334,68 @@ export function createAccountsApi() {
ipcRenderer.on('account:switch-failed', wrappedHandler);
return () => ipcRenderer.removeListener('account:switch-failed', wrappedHandler);
},
+
+ // --- Auth Recovery ---
+
+ /** Manually trigger auth recovery for a session */
+ triggerAuthRecovery: (sessionId: string): Promise<{ success: boolean; error?: string }> =>
+ ipcRenderer.invoke('accounts:trigger-auth-recovery', sessionId),
+
+ /** Subscribe to auth recovery started events */
+ onAuthRecoveryStarted: (handler: (data: {
+ sessionId: string;
+ accountId: string;
+ accountName: string;
+ }) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) =>
+ handler(data);
+ ipcRenderer.on('account:auth-recovery-started', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:auth-recovery-started', wrappedHandler);
+ },
+
+ /** Subscribe to auth recovery completed events */
+ onAuthRecoveryCompleted: (handler: (data: {
+ sessionId: string;
+ accountId: string;
+ accountName: string;
+ }) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) =>
+ handler(data);
+ ipcRenderer.on('account:auth-recovery-completed', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:auth-recovery-completed', wrappedHandler);
+ },
+
+ /** Subscribe to auth recovery failed events */
+ onAuthRecoveryFailed: (handler: (data: {
+ sessionId: string;
+ accountId: string;
+ accountName?: string;
+ error: string;
+ }) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) =>
+ handler(data);
+ ipcRenderer.on('account:auth-recovery-failed', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:auth-recovery-failed', wrappedHandler);
+ },
+
+ // --- Recovery Poller ---
+
+ /** Subscribe to account recovery available events (throttled accounts recovered by timer) */
+ onRecoveryAvailable: (handler: (data: {
+ recoveredAccountIds: string[];
+ recoveredCount: number;
+ stillThrottledCount: number;
+ totalAccounts: number;
+ }) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: any) =>
+ handler(data);
+ ipcRenderer.on('account:recovery-available', wrappedHandler);
+ return () => ipcRenderer.removeListener('account:recovery-available', wrappedHandler);
+ },
+
+ /** Manually trigger a recovery check (e.g., from a "Check Now" button) */
+ checkRecovery: (): Promise<{ recovered: string[] }> =>
+ ipcRenderer.invoke('accounts:check-recovery'),
};
}
diff --git a/src/main/stats/account-usage.ts b/src/main/stats/account-usage.ts
index ce3901ea1..789cb9729 100644
--- a/src/main/stats/account-usage.ts
+++ b/src/main/stats/account-usage.ts
@@ -208,6 +208,129 @@ export function getThrottleEvents(
}));
}
+// ============================================================================
+// Historical Aggregations
+// ============================================================================
+
+export interface AccountDailyUsage {
+ date: string; // YYYY-MM-DD
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ totalTokens: number;
+ costUsd: number;
+ queryCount: number;
+}
+
+export interface AccountMonthlyUsage {
+ month: string; // YYYY-MM
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ totalTokens: number;
+ costUsd: number;
+ queryCount: number;
+ daysActive: number;
+}
+
+const DAILY_USAGE_SQL = `
+ SELECT
+ date(start_time / 1000, 'unixepoch', 'localtime') as date,
+ COALESCE(SUM(input_tokens), 0) as inputTokens,
+ COALESCE(SUM(output_tokens), 0) as outputTokens,
+ COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens,
+ COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens,
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens), 0) as totalTokens,
+ COALESCE(SUM(cost_usd), 0) as costUsd,
+ COUNT(*) as queryCount
+ FROM query_events
+ WHERE account_id = ? AND start_time >= ? AND start_time < ?
+ GROUP BY date
+ ORDER BY date ASC
+`;
+
+const MONTHLY_USAGE_SQL = `
+ SELECT
+ strftime('%Y-%m', start_time / 1000, 'unixepoch', 'localtime') as month,
+ COALESCE(SUM(input_tokens), 0) as inputTokens,
+ COALESCE(SUM(output_tokens), 0) as outputTokens,
+ COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens,
+ COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens,
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens), 0) as totalTokens,
+ COALESCE(SUM(cost_usd), 0) as costUsd,
+ COUNT(*) as queryCount,
+ COUNT(DISTINCT date(start_time / 1000, 'unixepoch', 'localtime')) as daysActive
+ FROM query_events
+ WHERE account_id = ? AND start_time >= ? AND start_time < ?
+ GROUP BY month
+ ORDER BY month ASC
+`;
+
+/**
+ * Get daily token usage for an account over a date range.
+ * Returns one row per day with non-zero usage.
+ */
+export function getAccountDailyUsage(
+ db: Database.Database,
+ accountId: string,
+ sinceMs: number,
+ untilMs: number
+): AccountDailyUsage[] {
+ return stmtCache.get(db, DAILY_USAGE_SQL).all(accountId, sinceMs, untilMs) as AccountDailyUsage[];
+}
+
+/**
+ * Get monthly token usage for an account over a date range.
+ * Returns one row per month with non-zero usage.
+ */
+export function getAccountMonthlyUsage(
+ db: Database.Database,
+ accountId: string,
+ sinceMs: number,
+ untilMs: number
+): AccountMonthlyUsage[] {
+ return stmtCache.get(db, MONTHLY_USAGE_SQL).all(accountId, sinceMs, untilMs) as AccountMonthlyUsage[];
+}
+
+/**
+ * Get the 5-hour window usage history for an account (last N windows).
+ * Used for billing-window analysis and P90 prediction.
+ */
+export function getAccountWindowHistory(
+ db: Database.Database,
+ accountId: string,
+ windowCount: number = 40 // ~8 days of 5-hour windows
+): Array<{
+ windowStart: number;
+ windowEnd: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ costUsd: number;
+ queryCount: number;
+}> {
+ const sql = `
+ SELECT window_start as windowStart, window_end as windowEnd,
+ input_tokens as inputTokens, output_tokens as outputTokens,
+ cache_read_tokens as cacheReadTokens, cache_creation_tokens as cacheCreationTokens,
+ cost_usd as costUsd, query_count as queryCount
+ FROM account_usage_windows
+ WHERE account_id = ?
+ ORDER BY window_start DESC
+ LIMIT ?
+ `;
+ const rows = db.prepare(sql).all(accountId, windowCount) as Array<{
+ windowStart: number; windowEnd: number;
+ inputTokens: number; outputTokens: number;
+ cacheReadTokens: number; cacheCreationTokens: number;
+ costUsd: number; queryCount: number;
+ }>;
+ return rows.reverse(); // Return chronological order
+}
+
/**
* Clear the statement cache (call when database is closed)
*/
diff --git a/src/main/stats/stats-db.ts b/src/main/stats/stats-db.ts
index 3ecbda34d..0657bdb3e 100644
--- a/src/main/stats/stats-db.ts
+++ b/src/main/stats/stats-db.ts
@@ -59,9 +59,14 @@ import {
getAccountUsageInWindow,
insertThrottleEvent,
getThrottleEvents,
+ getAccountDailyUsage,
+ getAccountMonthlyUsage,
+ getAccountWindowHistory,
clearAccountUsageCache,
type AccountUsageTokens,
type AccountUsageSummary,
+ type AccountDailyUsage,
+ type AccountMonthlyUsage,
type ThrottleEvent,
} from './account-usage';
@@ -818,6 +823,18 @@ export class StatsDB {
return getThrottleEvents(this.database, accountId, since);
}
+ getAccountDailyUsage(accountId: string, sinceMs: number, untilMs: number): AccountDailyUsage[] {
+ return getAccountDailyUsage(this.database, accountId, sinceMs, untilMs);
+ }
+
+ getAccountMonthlyUsage(accountId: string, sinceMs: number, untilMs: number): AccountMonthlyUsage[] {
+ return getAccountMonthlyUsage(this.database, accountId, sinceMs, untilMs);
+ }
+
+ getAccountWindowHistory(accountId: string, windowCount?: number) {
+ return getAccountWindowHistory(this.database, accountId, windowCount);
+ }
+
// ============================================================================
// Timestamps
// ============================================================================
diff --git a/src/renderer/components/AccountUsageHistory.tsx b/src/renderer/components/AccountUsageHistory.tsx
new file mode 100644
index 000000000..d273ee1bb
--- /dev/null
+++ b/src/renderer/components/AccountUsageHistory.tsx
@@ -0,0 +1,147 @@
+import React, { useState, useEffect } from 'react';
+import type { Theme } from '../types';
+import { formatTokenCount } from '../hooks/useAccountUsage';
+
+interface AccountDailyUsage {
+ date: string;
+ totalTokens: number;
+ costUsd: number;
+ queryCount: number;
+}
+
+interface AccountMonthlyUsage {
+ month: string;
+ totalTokens: number;
+ costUsd: number;
+ queryCount: number;
+ daysActive: number;
+}
+
+type ViewMode = '7d' | '30d' | 'monthly';
+
+function formatDateLabel(label: string, view: ViewMode): string {
+ if (view === 'monthly') {
+ // YYYY-MM -> "Jan 26", "Feb 26", etc.
+ const [year, month] = label.split('-');
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
+ }
+ // YYYY-MM-DD -> "Feb 15", etc.
+ const [, month, day] = label.split('-');
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ return `${months[parseInt(month, 10) - 1]} ${parseInt(day, 10)}`;
+}
+
+export function AccountUsageHistory({ accountId, theme }: { accountId: string; theme: Theme }) {
+ const [view, setView] = useState('7d');
+ const [dailyData, setDailyData] = useState([]);
+ const [monthlyData, setMonthlyData] = useState([]);
+ const [throttleCount, setThrottleCount] = useState(0);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function load() {
+ setLoading(true);
+ try {
+ if (view === 'monthly') {
+ const data = await window.maestro.accounts.getMonthlyUsage(accountId, 6);
+ setMonthlyData(data as AccountMonthlyUsage[]);
+ } else {
+ const days = view === '7d' ? 7 : 30;
+ const data = await window.maestro.accounts.getDailyUsage(accountId, days);
+ setDailyData(data as AccountDailyUsage[]);
+ }
+
+ // Fetch throttle events for displayed time range
+ const sinceMs = view === 'monthly'
+ ? Date.now() - 6 * 30 * 24 * 60 * 60 * 1000
+ : Date.now() - (view === '7d' ? 7 : 30) * 24 * 60 * 60 * 1000;
+ const events = await window.maestro.accounts.getThrottleEvents(accountId, sinceMs);
+ setThrottleCount(events.length);
+ } catch { /* non-fatal */ }
+ setLoading(false);
+ }
+ load();
+ }, [accountId, view]);
+
+ const data: Array<{ totalTokens: number; costUsd: number; queryCount: number }> & Array =
+ view === 'monthly' ? monthlyData : dailyData;
+ const totalTokens = data.reduce((sum, d) => sum + d.totalTokens, 0);
+ const avgTokens = data.length > 0 ? totalTokens / data.length : 0;
+ const peakTokens = data.length > 0 ? Math.max(...data.map(d => d.totalTokens)) : 0;
+ const maxTokens = peakTokens || 1;
+
+ return (
+
+ {/* View tabs */}
+
+ {(['7d', '30d', 'monthly'] as const).map(v => (
+ setView(v)}
+ className={`text-xs px-2 py-1 rounded ${view === v ? 'font-bold' : ''}`}
+ style={{
+ backgroundColor: view === v ? theme.colors.accent + '20' : 'transparent',
+ color: view === v ? theme.colors.accent : theme.colors.textDim,
+ }}
+ >
+ {v === '7d' ? 'Last 7 Days' : v === '30d' ? '30 Days' : 'Monthly'}
+
+ ))}
+
+
+ {/* Data rows */}
+ {loading ? (
+
Loading...
+ ) : data.length === 0 ? (
+
No usage data yet
+ ) : (
+
+ {data.map((row, i) => {
+ const label = 'date' in row ? (row as AccountDailyUsage).date : (row as AccountMonthlyUsage).month;
+ const barWidth = maxTokens > 0 ? (row.totalTokens / maxTokens) * 100 : 0;
+ return (
+
+
+ {formatDateLabel(label, view)}
+
+
+
+ {formatTokenCount(row.totalTokens)}
+
+
+ ${row.costUsd.toFixed(2)}
+
+
+ {row.queryCount} qry
+
+
+ );
+ })}
+
+ )}
+
+ {/* Summary footer */}
+ {data.length > 0 && (
+
+ Avg: {formatTokenCount(Math.round(avgTokens))}/{view === 'monthly' ? 'mo' : 'day'}
+ Peak: {formatTokenCount(peakTokens)}
+ Throttles: 0 ? '#ef4444' : theme.colors.textMain
+ }}>{throttleCount}
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index a91f14b5f..e4a059e9c 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -11,11 +11,25 @@ import {
Check,
Wrench,
Download,
+ History,
} from 'lucide-react';
import type { Theme } from '../types';
import type { AccountProfile, AccountSwitchConfig } from '../../shared/account-types';
import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
+import { AccountUsageHistory } from './AccountUsageHistory';
+
+const PLAN_PRESETS = [
+ { label: 'Custom', tokens: 0, cost: null },
+ { label: 'Claude Pro', tokens: 19_000, cost: 18.00 },
+ { label: 'Claude Max 5', tokens: 88_000, cost: 35.00 },
+ { label: 'Claude Max 20', tokens: 220_000, cost: 140.00 },
+] as const;
+
+function renderConfidenceDots(confidence: 'low' | 'medium' | 'high'): string {
+ const filled = confidence === 'high' ? 3 : confidence === 'medium' ? 2 : 1;
+ return '\u25CF'.repeat(filled) + '\u25CB'.repeat(3 - filled);
+}
interface AccountsPanelProps {
theme: Theme;
@@ -53,6 +67,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
const [createdConfigDir, setCreatedConfigDir] = useState('');
const [loginCommand, setLoginCommand] = useState('');
const [editingAccountId, setEditingAccountId] = useState(null);
+ const [historyExpandedId, setHistoryExpandedId] = useState(null);
const [conflictingSessions, setConflictingSessions] = useState([]);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState(null);
@@ -528,6 +543,77 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
)}
+
+ {/* Prediction section */}
+ {usage.prediction && usage.limitTokens > 0 && (
+
+
Prediction
+
+
+ Current rate:{' '}
+
+ {usage.prediction.linearTimeToLimitMs !== null
+ ? `~${formatTimeRemaining(usage.prediction.linearTimeToLimitMs)} to limit`
+ : '\u2014'}
+
+
+
+ Conservative (P90):{' '}
+
+ {usage.prediction.windowsRemainingP90 !== null
+ ? `~${usage.prediction.windowsRemainingP90.toFixed(1)} windows`
+ : '\u2014'}
+
+
+
+ Confidence:{' '}
+
+ {renderConfidenceDots(usage.prediction.confidence)}
+
+
+ {usage.prediction.confidence === 'high' ? 'High' : usage.prediction.confidence === 'medium' ? 'Medium' : 'Low'}
+
+
+
+ Avg/window:{' '}
+
+ {formatTokenCount(Math.round(usage.prediction.avgTokensPerWindow))}
+
+
+
+
+ )}
+
+ {/* Usage History toggle */}
+
setHistoryExpandedId(
+ historyExpandedId === account.id ? null : account.id
+ )}
+ className="mt-2 flex items-center gap-1.5 text-xs hover:underline"
+ style={{ color: theme.colors.textDim }}
+ >
+
+ {historyExpandedId === account.id ? 'Hide' : 'Usage'} History
+ {historyExpandedId === account.id
+ ?
+ :
+ }
+
+ {historyExpandedId === account.id && (
+
+ )}
);
})()}
@@ -594,24 +680,47 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
className="mt-3 pt-3 space-y-3"
style={{ borderTop: `1px solid ${theme.colors.border}` }}
>
-
-
-
+
+ Plan preset / Token limit per window
+
+
+ p.tokens === account.tokenLimitPerWindow)?.label ?? 'Custom'}
+ onChange={(e) => {
+ const preset = PLAN_PRESETS.find(p => p.label === e.target.value);
+ if (preset && preset.tokens > 0) {
+ handleUpdateAccount(account.id, { tokenLimitPerWindow: preset.tokens });
+ }
+ }}
+ className="flex-1 p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
>
- Token limit per window (0 = no limit)
-
+ {PLAN_PRESETS.map(p => (
+
+ {p.label}{p.tokens > 0 ? ` (${formatTokenCount(p.tokens)})` : ''}
+
+ ))}
+
handleUpdateAccount(account.id, {
tokenLimitPerWindow:
parseInt(e.target.value) || 0,
})
}
- className="w-full p-2 rounded border bg-transparent outline-none text-xs font-mono"
+ placeholder="Custom limit"
+ className="w-28 p-2 rounded border bg-transparent outline-none text-xs font-mono"
style={{
borderColor: theme.colors.border,
color: theme.colors.textMain,
@@ -619,6 +728,9 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
min={0}
/>
+
+
+
Promise;
getAllUsage: () => Promise;
getThrottleEvents: (accountId?: string, since?: number) => Promise;
+ getDailyUsage: (accountId: string, days?: number) => Promise;
+ getMonthlyUsage: (accountId: string, months?: number) => Promise;
+ getWindowHistory: (accountId: string, windowCount?: number) => Promise;
getSwitchConfig: () => Promise;
updateSwitchConfig: (updates: Record) => Promise;
getDefault: () => Promise;
@@ -2638,6 +2641,7 @@ interface MaestroAPI {
getLoginCommand: (configDir: string) => Promise;
removeDirectory: (configDir: string) => Promise<{ success: boolean; error?: string }>;
validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }) => Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }>;
+ syncCredentials: (configDir: string) => Promise<{ success: boolean; error?: string }>;
onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number; totalTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void;
onLimitWarning: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
onLimitReached: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
@@ -2653,6 +2657,12 @@ interface MaestroAPI {
onSwitchRespawn: (handler: (data: { sessionId: string; toAccountId: string; toAccountName: string; configDir: string; lastPrompt: string | null; reason: string }) => void) => () => void;
onSwitchCompleted: (handler: (data: Record) => void) => () => void;
onSwitchFailed: (handler: (data: Record) => void) => () => void;
+ triggerAuthRecovery: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
+ onAuthRecoveryStarted: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
+ onAuthRecoveryCompleted: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
+ onAuthRecoveryFailed: (handler: (data: { sessionId: string; accountId: string; accountName?: string; error: string }) => void) => () => void;
+ onRecoveryAvailable: (handler: (data: { recoveredAccountIds: string[]; recoveredCount: number; stillThrottledCount: number; totalAccounts: number }) => void) => () => void;
+ checkRecovery: () => Promise<{ recovered: string[] }>;
};
// Director's Notes API (unified history + synopsis generation)
diff --git a/src/renderer/hooks/useAccountUsage.ts b/src/renderer/hooks/useAccountUsage.ts
index 0c717e072..de56ab2f7 100644
--- a/src/renderer/hooks/useAccountUsage.ts
+++ b/src/renderer/hooks/useAccountUsage.ts
@@ -1,5 +1,28 @@
import { useState, useEffect, useCallback, useRef } from 'react';
+// ============================================================================
+// Prediction Types
+// ============================================================================
+
+export interface UsagePrediction {
+ /** Linear estimate: remaining tokens / current burn rate */
+ linearTimeToLimitMs: number | null;
+ /** Weighted estimate using recent window patterns */
+ weightedTimeToLimitMs: number | null;
+ /** P90 tokens per window (90th percentile of recent windows) */
+ p90TokensPerWindow: number;
+ /** Average tokens per window */
+ avgTokensPerWindow: number;
+ /** Confidence: 'low' (<5 windows), 'medium' (5-15), 'high' (>15) */
+ confidence: 'low' | 'medium' | 'high';
+ /** Predicted number of windows remaining before limit (at P90 rate) */
+ windowsRemainingP90: number | null;
+}
+
+// ============================================================================
+// Metrics Types
+// ============================================================================
+
export interface AccountUsageMetrics {
accountId: string;
totalTokens: number;
@@ -13,17 +36,125 @@ export interface AccountUsageMetrics {
burnRatePerHour: number;
estimatedTimeToLimitMs: number | null; // null if no limit or burn rate is 0
status: string;
+ prediction: UsagePrediction;
}
const DEFAULT_INTERVAL_MS = 30_000;
const URGENT_INTERVAL_MS = 5_000;
const URGENT_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
+const EMPTY_PREDICTION: UsagePrediction = {
+ linearTimeToLimitMs: null,
+ weightedTimeToLimitMs: null,
+ p90TokensPerWindow: 0,
+ avgTokensPerWindow: 0,
+ confidence: 'low',
+ windowsRemainingP90: null,
+};
+
+// ============================================================================
+// P90 Prediction Calculator
+// ============================================================================
+
+/**
+ * Calculate P90-weighted prediction from billing window history.
+ * Uses exponential weighting so recent windows count more than older ones.
+ *
+ * Inspired by Claude-Code-Usage-Monitor's P90 approach but adapted for
+ * Maestro's multi-account context.
+ */
+export function calculatePrediction(
+ windowHistory: Array<{ totalTokens: number; windowStart: number; windowEnd: number }>,
+ currentWindowTokens: number,
+ limitTokens: number,
+ windowMs: number,
+): UsagePrediction {
+ const windowCount = windowHistory.length;
+ const confidence = windowCount < 5 ? 'low' : windowCount < 15 ? 'medium' : 'high';
+
+ if (windowCount === 0) {
+ return {
+ linearTimeToLimitMs: null,
+ weightedTimeToLimitMs: null,
+ p90TokensPerWindow: 0,
+ avgTokensPerWindow: 0,
+ confidence,
+ windowsRemainingP90: null,
+ };
+ }
+
+ // Extract token totals per window
+ const totals = windowHistory.map(w => w.totalTokens);
+
+ // Calculate average
+ const avgTokensPerWindow = totals.reduce((a, b) => a + b, 0) / totals.length;
+
+ // Calculate P90 (90th percentile)
+ const sorted = [...totals].sort((a, b) => a - b);
+ const p90Index = Math.floor(sorted.length * 0.9);
+ const p90TokensPerWindow = sorted[Math.min(p90Index, sorted.length - 1)];
+
+ // Weighted average: exponential decay, most recent windows weighted highest
+ // Weight = 0.85^(age), so most recent window = 1.0, one back = 0.85, etc.
+ const DECAY = 0.85;
+ let weightedSum = 0;
+ let weightTotal = 0;
+ for (let i = 0; i < totals.length; i++) {
+ const age = totals.length - 1 - i; // 0 = most recent
+ const weight = Math.pow(DECAY, age);
+ weightedSum += totals[i] * weight;
+ weightTotal += weight;
+ }
+ const weightedAvg = weightedSum / weightTotal;
+
+ // Predictions (only if limit is configured)
+ let linearTimeToLimitMs: number | null = null;
+ let weightedTimeToLimitMs: number | null = null;
+ let windowsRemainingP90: number | null = null;
+
+ if (limitTokens > 0) {
+ const remaining = Math.max(0, limitTokens - currentWindowTokens);
+
+ // Linear: remaining / (average tokens per window) * window duration
+ if (avgTokensPerWindow > 0) {
+ const windowsRemaining = remaining / avgTokensPerWindow;
+ linearTimeToLimitMs = windowsRemaining * windowMs;
+ }
+
+ // Weighted: use weighted average for more responsive prediction
+ if (weightedAvg > 0) {
+ const windowsRemaining = remaining / weightedAvg;
+ weightedTimeToLimitMs = windowsRemaining * windowMs;
+ }
+
+ // P90: conservative estimate
+ if (p90TokensPerWindow > 0) {
+ windowsRemainingP90 = remaining / p90TokensPerWindow;
+ }
+ }
+
+ return {
+ linearTimeToLimitMs,
+ weightedTimeToLimitMs,
+ p90TokensPerWindow,
+ avgTokensPerWindow,
+ confidence,
+ windowsRemainingP90,
+ };
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
/**
* Hook that provides real-time per-account usage metrics.
* Fetches on mount, subscribes to real-time updates, and recalculates
* derived metrics (burn rate, time to limit) every 30 seconds.
* Switches to 5-second updates when any account is within 5 minutes of reset.
+ *
+ * Also fetches billing window history once on mount for P90 prediction
+ * and recalculates predictions when the current window's usage changes.
*/
export function useAccountUsage(): {
metrics: Record;
@@ -34,6 +165,7 @@ export function useAccountUsage(): {
const [loading, setLoading] = useState(true);
const intervalRef = useRef | null>(null);
const currentIntervalMs = useRef(DEFAULT_INTERVAL_MS);
+ const windowHistoriesRef = useRef>>({});
const calculateDerivedMetrics = useCallback((raw: {
accountId: string;
@@ -62,11 +194,20 @@ export function useAccountUsage(): {
estimatedTimeToLimitMs = hoursToLimit * 60 * 60 * 1000;
}
+ // P90 prediction from window history
+ const prediction = calculatePrediction(
+ windowHistoriesRef.current[raw.accountId] || [],
+ raw.totalTokens,
+ raw.limitTokens,
+ raw.windowEnd - raw.windowStart,
+ );
+
return {
...raw,
timeRemainingMs,
burnRatePerHour,
estimatedTimeToLimitMs,
+ prediction,
};
}, []);
@@ -118,6 +259,32 @@ export function useAccountUsage(): {
}
}, [calculateDerivedMetrics]);
+ // Load window histories once on mount for P90 predictions
+ useEffect(() => {
+ async function loadHistories() {
+ try {
+ const accounts = await window.maestro.accounts.list();
+ const histories: Record> = {};
+ for (const account of (accounts || []) as Array<{ id: string }>) {
+ try {
+ const history = await window.maestro.accounts.getWindowHistory(account.id, 40) as Array<{
+ inputTokens: number; outputTokens: number;
+ cacheReadTokens: number; cacheCreationTokens: number;
+ windowStart: number; windowEnd: number;
+ }>;
+ histories[account.id] = history.map(w => ({
+ totalTokens: w.inputTokens + w.outputTokens + w.cacheReadTokens + w.cacheCreationTokens,
+ windowStart: w.windowStart,
+ windowEnd: w.windowEnd,
+ }));
+ } catch { /* skip individual account errors */ }
+ }
+ windowHistoriesRef.current = histories;
+ } catch { /* non-fatal */ }
+ }
+ loadHistories();
+ }, []);
+
useEffect(() => {
fetchUsage();
From 7a55ed0ffaa85a8377f5c34160f17735004930d1 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 20:33:01 -0500
Subject: [PATCH 23/59] MAESTRO: feat: wire AccountSwitcher into main process
and IPC handlers
AccountSwitcher was fully implemented but never instantiated in index.ts,
leaving manual account switches from the renderer completely broken. This
adds the instantiation with correct dependencies and passes the getter to
both registerAccountHandlers and registerProcessHandlers, enabling the
accounts:execute-switch IPC and process:write recordLastPrompt paths.
Co-Authored-By: Claude Opus 4.6
---
.../accounts/account-switcher-wiring.test.ts | 288 ++++++++++++++++++
src/main/index.ts | 18 ++
2 files changed, 306 insertions(+)
create mode 100644 src/__tests__/main/accounts/account-switcher-wiring.test.ts
diff --git a/src/__tests__/main/accounts/account-switcher-wiring.test.ts b/src/__tests__/main/accounts/account-switcher-wiring.test.ts
new file mode 100644
index 000000000..9d785f40f
--- /dev/null
+++ b/src/__tests__/main/accounts/account-switcher-wiring.test.ts
@@ -0,0 +1,288 @@
+/**
+ * Integration tests for AccountSwitcher wiring.
+ *
+ * Verifies that registerAccountHandlers and registerProcessHandlers
+ * properly call through to the AccountSwitcher when the getter is provided,
+ * and gracefully degrade when it is not.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ipcMain } from 'electron';
+import { registerAccountHandlers } from '../../../main/ipc/handlers/accounts';
+import { registerProcessHandlers } from '../../../main/ipc/handlers/process';
+
+// Mock electron's ipcMain
+vi.mock('electron', () => ({
+ ipcMain: {
+ handle: vi.fn(),
+ removeHandler: vi.fn(),
+ },
+}));
+
+// Mock the stats module
+vi.mock('../../../main/stats', () => ({
+ getStatsDB: vi.fn(() => ({
+ isReady: () => false,
+ getAccountUsageInWindow: vi.fn(),
+ getThrottleEvents: vi.fn().mockReturnValue([]),
+ insertThrottleEvent: vi.fn(),
+ })),
+}));
+
+// Mock the logger
+vi.mock('../../../main/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+// Mock account-setup module (required by accounts handler)
+vi.mock('../../../main/accounts/account-setup', () => ({
+ validateBaseClaudeDir: vi.fn(),
+ discoverExistingAccounts: vi.fn(),
+ createAccountDirectory: vi.fn(),
+ validateAccountSymlinks: vi.fn(),
+ repairAccountSymlinks: vi.fn(),
+ readAccountEmail: vi.fn(),
+ buildLoginCommand: vi.fn(),
+ removeAccountDirectory: vi.fn(),
+ validateRemoteAccountDir: vi.fn(),
+ syncCredentialsFromBase: vi.fn(),
+}));
+
+// Mock agent-args utilities (required by process handler)
+vi.mock('../../../main/utils/agent-args', () => ({
+ buildAgentArgs: vi.fn((_agent: unknown, opts: { baseArgs?: string[] }) => opts.baseArgs || []),
+ applyAgentConfigOverrides: vi.fn((_agent: unknown, args: string[]) => ({
+ args,
+ modelSource: 'none' as const,
+ customArgsSource: 'none' as const,
+ customEnvSource: 'none' as const,
+ effectiveCustomEnvVars: undefined,
+ })),
+ getContextWindowValue: vi.fn(() => 0),
+}));
+
+// Mock node-pty
+vi.mock('node-pty', () => ({
+ spawn: vi.fn(),
+}));
+
+// Mock streamJsonBuilder
+vi.mock('../../../main/process-manager/utils/streamJsonBuilder', () => ({
+ buildStreamJsonMessage: vi.fn(),
+}));
+
+// Mock ssh-command-builder
+vi.mock('../../../main/utils/ssh-command-builder', () => ({
+ buildSshCommandWithStdin: vi.fn(),
+}));
+
+function createMinimalAccountRegistry() {
+ return {
+ getAll: vi.fn().mockReturnValue([]),
+ get: vi.fn(),
+ add: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ setStatus: vi.fn(),
+ getDefaultAccount: vi.fn(),
+ selectNextAccount: vi.fn(),
+ getSwitchConfig: vi.fn().mockReturnValue({ enabled: false }),
+ updateSwitchConfig: vi.fn(),
+ assignToSession: vi.fn(),
+ getAssignment: vi.fn(),
+ getAllAssignments: vi.fn().mockReturnValue([]),
+ removeAssignment: vi.fn(),
+ reconcileAssignments: vi.fn().mockReturnValue(0),
+ } as any;
+}
+
+describe('AccountSwitcher wiring', () => {
+ let handlers: Map;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ handlers = new Map();
+
+ // Capture registered handlers
+ vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => {
+ handlers.set(channel, handler);
+ return undefined as any;
+ });
+ });
+
+ describe('accounts:execute-switch', () => {
+ it('should execute switch when getAccountSwitcher returns an instance', async () => {
+ const mockSwitcher = {
+ executeSwitch: vi.fn().mockResolvedValue({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'manual',
+ automatic: false,
+ timestamp: Date.now(),
+ }),
+ recordLastPrompt: vi.fn(),
+ cleanupSession: vi.fn(),
+ };
+
+ registerAccountHandlers({
+ getAccountRegistry: () => createMinimalAccountRegistry(),
+ getAccountSwitcher: () => mockSwitcher as any,
+ });
+
+ const handler = handlers.get('accounts:execute-switch')!;
+ expect(handler).toBeDefined();
+
+ const result = await handler({}, {
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'manual',
+ automatic: false,
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.event).toBeDefined();
+ expect(mockSwitcher.executeSwitch).toHaveBeenCalledWith({
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'manual',
+ automatic: false,
+ });
+ });
+
+ it('should return error when getAccountSwitcher returns null', async () => {
+ registerAccountHandlers({
+ getAccountRegistry: () => createMinimalAccountRegistry(),
+ getAccountSwitcher: () => null,
+ });
+
+ const handler = handlers.get('accounts:execute-switch')!;
+ const result = await handler({}, {
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'manual',
+ automatic: false,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Account switcher not initialized');
+ });
+
+ it('should return error when getAccountSwitcher is not provided', async () => {
+ registerAccountHandlers({
+ getAccountRegistry: () => createMinimalAccountRegistry(),
+ // No getAccountSwitcher provided
+ });
+
+ const handler = handlers.get('accounts:execute-switch')!;
+ const result = await handler({}, {
+ sessionId: 'session-1',
+ fromAccountId: 'acct-1',
+ toAccountId: 'acct-2',
+ reason: 'manual',
+ automatic: false,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Account switcher not initialized');
+ });
+ });
+
+ describe('accounts:cleanup-session', () => {
+ it('should call switcher.cleanupSession when switcher is available', async () => {
+ const mockSwitcher = {
+ executeSwitch: vi.fn(),
+ recordLastPrompt: vi.fn(),
+ cleanupSession: vi.fn(),
+ };
+
+ registerAccountHandlers({
+ getAccountRegistry: () => createMinimalAccountRegistry(),
+ getAccountSwitcher: () => mockSwitcher as any,
+ });
+
+ const handler = handlers.get('accounts:cleanup-session')!;
+ expect(handler).toBeDefined();
+
+ const result = await handler({}, 'session-1');
+
+ expect(result.success).toBe(true);
+ expect(mockSwitcher.cleanupSession).toHaveBeenCalledWith('session-1');
+ });
+ });
+
+ describe('process:write recordLastPrompt', () => {
+ it('should record last prompt on process:write when switcher available', async () => {
+ const mockSwitcher = {
+ executeSwitch: vi.fn(),
+ recordLastPrompt: vi.fn(),
+ cleanupSession: vi.fn(),
+ };
+
+ const mockProcessManager = {
+ write: vi.fn().mockReturnValue(true),
+ spawn: vi.fn(),
+ kill: vi.fn(),
+ interrupt: vi.fn(),
+ resize: vi.fn(),
+ getActiveProcesses: vi.fn().mockReturnValue([]),
+ };
+
+ registerProcessHandlers({
+ getProcessManager: () => mockProcessManager as any,
+ getAgentDetector: () => null,
+ agentConfigsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any,
+ settingsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any,
+ getMainWindow: () => null,
+ sessionsStore: { get: vi.fn().mockReturnValue({ sessions: [] }), set: vi.fn(), onDidChange: vi.fn() } as any,
+ getAccountSwitcher: () => mockSwitcher as any,
+ safeSend: vi.fn(),
+ });
+
+ const handler = handlers.get('process:write')!;
+ expect(handler).toBeDefined();
+
+ await handler({}, 'session-1', 'Hello, fix the bug');
+
+ expect(mockSwitcher.recordLastPrompt).toHaveBeenCalledWith('session-1', 'Hello, fix the bug');
+ expect(mockProcessManager.write).toHaveBeenCalledWith('session-1', 'Hello, fix the bug');
+ });
+
+ it('should not fail on process:write when switcher is not available', async () => {
+ const mockProcessManager = {
+ write: vi.fn().mockReturnValue(true),
+ spawn: vi.fn(),
+ kill: vi.fn(),
+ interrupt: vi.fn(),
+ resize: vi.fn(),
+ getActiveProcesses: vi.fn().mockReturnValue([]),
+ };
+
+ registerProcessHandlers({
+ getProcessManager: () => mockProcessManager as any,
+ getAgentDetector: () => null,
+ agentConfigsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any,
+ settingsStore: { get: vi.fn().mockReturnValue({}), set: vi.fn(), onDidChange: vi.fn() } as any,
+ getMainWindow: () => null,
+ sessionsStore: { get: vi.fn().mockReturnValue({ sessions: [] }), set: vi.fn(), onDidChange: vi.fn() } as any,
+ // No getAccountSwitcher provided
+ safeSend: vi.fn(),
+ });
+
+ const handler = handlers.get('process:write')!;
+ const result = await handler({}, 'session-1', 'Hello');
+
+ // Should succeed without error — graceful degradation
+ expect(result).toBe(true);
+ expect(mockProcessManager.write).toHaveBeenCalledWith('session-1', 'Hello');
+ });
+ });
+});
diff --git a/src/main/index.ts b/src/main/index.ts
index e62b31271..a363c4e04 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -62,6 +62,7 @@ import { AccountRegistry } from './accounts/account-registry';
import { AccountThrottleHandler } from './accounts/account-throttle-handler';
import { AccountAuthRecovery } from './accounts/account-auth-recovery';
import { AccountRecoveryPoller } from './accounts/account-recovery-poller';
+import { AccountSwitcher } from './accounts/account-switcher';
import { getAccountStore } from './stores';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import {
@@ -234,6 +235,7 @@ let accountRegistry: AccountRegistry | null = null;
let accountThrottleHandler: AccountThrottleHandler | null = null;
let accountAuthRecovery: AccountAuthRecovery | null = null;
let accountRecoveryPoller: AccountRecoveryPoller | null = null;
+let accountSwitcher: AccountSwitcher | null = null;
// Create safeSend with dependency injection (Phase 2 refactoring)
const safeSend = createSafeSend(() => mainWindow);
@@ -391,6 +393,20 @@ app.whenReady().then(async () => {
}
}
+ // Initialize account switcher for manual account switching from renderer
+ if (accountRegistry && processManager) {
+ try {
+ accountSwitcher = new AccountSwitcher(
+ processManager,
+ accountRegistry,
+ safeSend,
+ );
+ logger.info('Account switcher initialized', 'Startup');
+ } catch (error) {
+ logger.error(`Failed to initialize account switcher: ${error}`, 'Startup');
+ }
+ }
+
// Set up IPC handlers
logger.debug('Setting up IPC handlers', 'Startup');
setupIpcHandlers();
@@ -514,6 +530,7 @@ function setupIpcHandlers() {
sessionsStore,
getAccountRegistry: () => accountRegistry,
getAccountAuthRecovery: () => accountAuthRecovery,
+ getAccountSwitcher: () => accountSwitcher,
safeSend,
});
@@ -614,6 +631,7 @@ function setupIpcHandlers() {
getAccountRegistry: () => accountRegistry,
getAccountAuthRecovery: () => accountAuthRecovery,
getRecoveryPoller: () => accountRecoveryPoller,
+ getAccountSwitcher: () => accountSwitcher,
});
// Register Document Graph handlers for file watching
From ce9aafc609c4456b05e3caf32be08d9c2835d813 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 20:39:20 -0500
Subject: [PATCH 24/59] MAESTRO: feat: add auth recovery service, tests, and
dedup getWindowBounds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add AccountAuthRecovery class: automatic OAuth re-authentication
when agents encounter expired tokens (kill → login → respawn)
- Add 21 unit tests covering happy path, timeout, credential sync
fallback, concurrent recovery guard, spawn errors, and edge cases
- Wire auth recovery into error-listener for auth_expired errors
- Consolidate getWindowBounds() duplicate: remove local copy from
account-usage-listener.ts, import from account-utils.ts
- Update error-listener, process-listeners/index, types for auth
recovery dependency injection
- Add syncCredentialsFromBase fallback in account-setup.ts
- Update renderer components and hooks for auth recovery state
Co-Authored-By: Claude Opus 4.6
---
.../accounts/account-auth-recovery.test.ts | 495 ++++++++++++++++++
.../main/accounts/account-setup.test.ts | 4 +-
.../hooks/useAgentErrorRecovery.test.ts | 2 +-
.../renderer/stores/agentStore.test.ts | 30 +-
src/main/accounts/account-auth-recovery.ts | 282 ++++++++++
src/main/accounts/account-setup.ts | 57 +-
.../account-usage-listener.ts | 17 +-
src/main/process-listeners/error-listener.ts | 49 +-
src/main/process-listeners/index.ts | 3 +-
src/main/process-listeners/types.ts | 3 +
src/renderer/components/AccountsPanel.tsx | 2 +-
src/renderer/components/NewInstanceModal.tsx | 41 +-
src/renderer/components/SessionList.tsx | 2 +-
src/renderer/components/VirtuososModal.tsx | 2 +-
.../hooks/agent/useAgentErrorRecovery.tsx | 13 +-
src/renderer/stores/agentStore.ts | 8 +-
16 files changed, 941 insertions(+), 69 deletions(-)
create mode 100644 src/__tests__/main/accounts/account-auth-recovery.test.ts
create mode 100644 src/main/accounts/account-auth-recovery.ts
diff --git a/src/__tests__/main/accounts/account-auth-recovery.test.ts b/src/__tests__/main/accounts/account-auth-recovery.test.ts
new file mode 100644
index 000000000..97a2854c9
--- /dev/null
+++ b/src/__tests__/main/accounts/account-auth-recovery.test.ts
@@ -0,0 +1,495 @@
+/**
+ * Tests for AccountAuthRecovery.
+ * Validates auth recovery flow: process killing, claude login spawning,
+ * timeout handling, credential sync fallback, and respawn orchestration.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { AccountProfile } from '../../../shared/account-types';
+
+// Hoist mock functions for use in vi.mock factories
+const {
+ mockSpawn,
+ mockAccess,
+ mockSyncCredentialsFromBase,
+} = vi.hoisted(() => ({
+ mockSpawn: vi.fn(),
+ mockAccess: vi.fn(),
+ mockSyncCredentialsFromBase: vi.fn(),
+}));
+
+// Mock child_process.spawn
+vi.mock('child_process', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ default: { ...actual, spawn: mockSpawn },
+ spawn: mockSpawn,
+ };
+});
+
+// Mock fs/promises
+vi.mock('fs/promises', () => ({
+ default: { access: mockAccess },
+ access: mockAccess,
+}));
+
+// Mock logger
+vi.mock('../../../main/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ },
+}));
+
+// Mock account-setup (syncCredentialsFromBase)
+vi.mock('../../../main/accounts/account-setup', () => ({
+ syncCredentialsFromBase: mockSyncCredentialsFromBase,
+}));
+
+import { AccountAuthRecovery } from '../../../main/accounts/account-auth-recovery';
+import type { ProcessManager } from '../../../main/process-manager/ProcessManager';
+import type { AccountRegistry } from '../../../main/accounts/account-registry';
+import type { AgentDetector } from '../../../main/agents';
+import type { SafeSendFn } from '../../../main/utils/safe-send';
+import { EventEmitter } from 'events';
+
+function createMockAccount(overrides: Partial = {}): AccountProfile {
+ return {
+ id: 'acct-1',
+ name: 'Test Account',
+ email: 'test@example.com',
+ configDir: '/home/test/.claude-test',
+ agentType: 'claude-code',
+ status: 'active',
+ authMethod: 'oauth',
+ addedAt: Date.now(),
+ lastUsedAt: Date.now(),
+ lastThrottledAt: 0,
+ tokenLimitPerWindow: 0,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ isDefault: true,
+ autoSwitchEnabled: true,
+ ...overrides,
+ };
+}
+
+/**
+ * Creates a mock child process (EventEmitter) with stdout/stderr streams.
+ * Returns the child and a helper to simulate exit.
+ */
+function createMockChildProcess() {
+ const child = new EventEmitter() as EventEmitter & {
+ stdout: EventEmitter;
+ stderr: EventEmitter;
+ kill: ReturnType;
+ pid: number;
+ };
+ child.stdout = new EventEmitter();
+ child.stderr = new EventEmitter();
+ child.kill = vi.fn();
+ child.pid = 12345;
+ return child;
+}
+
+describe('AccountAuthRecovery', () => {
+ let recovery: AccountAuthRecovery;
+ let mockProcessManager: {
+ kill: ReturnType;
+ };
+ let mockAccountRegistry: {
+ get: ReturnType;
+ setStatus: ReturnType;
+ };
+ let mockAgentDetector: {
+ getAgent: ReturnType;
+ };
+ let mockSafeSend: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+
+ mockProcessManager = {
+ kill: vi.fn().mockReturnValue(true),
+ };
+
+ mockAccountRegistry = {
+ get: vi.fn().mockReturnValue(createMockAccount()),
+ setStatus: vi.fn(),
+ };
+
+ mockAgentDetector = {
+ getAgent: vi.fn().mockResolvedValue({ path: '/usr/bin/claude', command: 'claude' }),
+ };
+
+ mockSafeSend = vi.fn();
+
+ recovery = new AccountAuthRecovery(
+ mockProcessManager as unknown as ProcessManager,
+ mockAccountRegistry as unknown as AccountRegistry,
+ mockAgentDetector as unknown as AgentDetector,
+ mockSafeSend as SafeSendFn,
+ );
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe('recoverAuth', () => {
+ it('should kill the current agent process before starting recovery', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+
+ // Advance past KILL_DELAY_MS
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // Simulate successful login
+ mockChild.emit('close', 0);
+ await promise;
+
+ expect(mockProcessManager.kill).toHaveBeenCalledWith('session-1');
+ });
+
+ it('should spawn claude login with correct CLAUDE_CONFIG_DIR', async () => {
+ const account = createMockAccount({ configDir: '/home/test/.claude-work' });
+ mockAccountRegistry.get.mockReturnValue(account);
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+ await promise;
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ '/usr/bin/claude',
+ ['login'],
+ expect.objectContaining({
+ env: expect.objectContaining({
+ CLAUDE_CONFIG_DIR: '/home/test/.claude-work',
+ }),
+ }),
+ );
+ });
+
+ it('should emit auth-recovery-started event', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+ await promise;
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:auth-recovery-started', {
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ accountName: 'Test Account',
+ });
+ });
+
+ it('should emit auth-recovery-completed on successful login', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // Login exits successfully and credentials exist
+ mockChild.emit('close', 0);
+
+ const result = await promise;
+
+ expect(result).toBe(true);
+ expect(mockSafeSend).toHaveBeenCalledWith('account:auth-recovery-completed', {
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ accountName: 'Test Account',
+ });
+ });
+
+ it('should emit auth-recovery-failed on login timeout', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+
+ // Advance past KILL_DELAY_MS
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // Advance past LOGIN_TIMEOUT_MS (120s)
+ await vi.advanceTimersByTimeAsync(120_000);
+
+ // Sync fallback also fails
+ mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No credentials' });
+
+ await promise;
+
+ expect(mockChild.kill).toHaveBeenCalledWith('SIGTERM');
+ });
+
+ it('should fall back to credential sync when login fails', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockSyncCredentialsFromBase.mockResolvedValue({ success: true });
+ mockAccess.mockResolvedValue(undefined); // credentials exist after sync
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // Login exits with failure code
+ mockChild.emit('close', 1);
+
+ const result = await promise;
+
+ expect(mockSyncCredentialsFromBase).toHaveBeenCalledWith('/home/test/.claude-test');
+ expect(result).toBe(true);
+ });
+
+ it('should emit respawn event after successful recovery', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ // Record a prompt before recovery
+ recovery.recordLastPrompt('session-1', 'Tell me about TypeScript');
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+ await promise;
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', {
+ sessionId: 'session-1',
+ toAccountId: 'acct-1',
+ toAccountName: 'Test Account',
+ configDir: '/home/test/.claude-test',
+ lastPrompt: 'Tell me about TypeScript',
+ reason: 'auth-recovery',
+ });
+ });
+
+ it('should update account status to active after successful recovery', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+ await promise;
+
+ // First call sets status to 'expired', second call to 'active'
+ expect(mockAccountRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'expired');
+ expect(mockAccountRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'active');
+ });
+
+ it('should not start concurrent recoveries for same session', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise1 = recovery.recoverAuth('session-1', 'acct-1');
+ const result2 = await recovery.recoverAuth('session-1', 'acct-1');
+
+ expect(result2).toBe(false);
+
+ // Only one spawn should have occurred
+ // Advance and resolve the first
+ await vi.advanceTimersByTimeAsync(1000);
+ mockChild.emit('close', 0);
+ await promise1;
+
+ // Spawn called only once (for the first call)
+ expect(mockSpawn).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle missing account gracefully', async () => {
+ mockAccountRegistry.get.mockReturnValue(null);
+
+ const result = await recovery.recoverAuth('session-1', 'acct-missing');
+
+ expect(result).toBe(false);
+ expect(mockSpawn).not.toHaveBeenCalled();
+ });
+
+ it('should mark account as expired at start of recovery', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+
+ // Should be called before waiting for login
+ expect(mockAccountRegistry.setStatus).toHaveBeenCalledWith('acct-1', 'expired');
+
+ await vi.advanceTimersByTimeAsync(1000);
+ mockChild.emit('close', 0);
+ await promise;
+ });
+
+ it('should handle spawn errors gracefully', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No creds' });
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // Emit spawn error
+ mockChild.emit('error', new Error('ENOENT: claude not found'));
+
+ const result = await promise;
+
+ expect(result).toBe(false);
+ });
+
+ it('should send null lastPrompt when no prompt was recorded', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+ await promise;
+
+ expect(mockSafeSend).toHaveBeenCalledWith('account:switch-respawn', expect.objectContaining({
+ lastPrompt: null,
+ }));
+ });
+
+ it('should emit auth-recovery-failed when both login and sync fail', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No credentials found' });
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // Login fails
+ mockChild.emit('close', 1);
+
+ const result = await promise;
+
+ expect(result).toBe(false);
+ expect(mockSafeSend).toHaveBeenCalledWith('account:auth-recovery-failed', expect.objectContaining({
+ sessionId: 'session-1',
+ accountId: 'acct-1',
+ accountName: 'Test Account',
+ }));
+ });
+
+ it('should allow recovery for same session after previous recovery completes', async () => {
+ const mockChild1 = createMockChildProcess();
+ const mockChild2 = createMockChildProcess();
+ mockSpawn.mockReturnValueOnce(mockChild1).mockReturnValueOnce(mockChild2);
+ mockAccess.mockResolvedValue(undefined);
+
+ // First recovery
+ const promise1 = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+ mockChild1.emit('close', 0);
+ await promise1;
+
+ // Second recovery (should work since first completed)
+ const promise2 = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+ mockChild2.emit('close', 0);
+ const result2 = await promise2;
+
+ expect(result2).toBe(true);
+ expect(mockSpawn).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle login exit code 0 but missing credentials', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ // credentials file does not exist
+ mockAccess.mockRejectedValue(new Error('ENOENT'));
+ mockSyncCredentialsFromBase.mockResolvedValue({ success: false, error: 'No creds' });
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+
+ const result = await promise;
+
+ // Should fall through to sync fallback and eventually fail
+ expect(result).toBe(false);
+ expect(mockSyncCredentialsFromBase).toHaveBeenCalled();
+ });
+
+ it('should use fallback binary name when agent path is unavailable', async () => {
+ mockAgentDetector.getAgent.mockResolvedValue(null);
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+ mockAccess.mockResolvedValue(undefined);
+
+ const promise = recovery.recoverAuth('session-1', 'acct-1');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ mockChild.emit('close', 0);
+ await promise;
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ 'claude',
+ ['login'],
+ expect.any(Object),
+ );
+ });
+ });
+
+ describe('recordLastPrompt', () => {
+ it('should store prompt for later use in respawn events', () => {
+ recovery.recordLastPrompt('session-1', 'Hello world');
+ // Verified indirectly through recoverAuth respawn event
+ expect(() => recovery.recordLastPrompt('session-1', 'Hello world')).not.toThrow();
+ });
+ });
+
+ describe('isRecovering', () => {
+ it('should return false when no recovery is in progress', () => {
+ expect(recovery.isRecovering('session-1')).toBe(false);
+ });
+
+ it('should return true when recovery is in progress', async () => {
+ const mockChild = createMockChildProcess();
+ mockSpawn.mockReturnValue(mockChild);
+
+ recovery.recoverAuth('session-1', 'acct-1');
+
+ expect(recovery.isRecovering('session-1')).toBe(true);
+
+ // Clean up
+ await vi.advanceTimersByTimeAsync(1000);
+ mockAccess.mockResolvedValue(undefined);
+ mockChild.emit('close', 0);
+ await vi.advanceTimersByTimeAsync(0);
+ });
+ });
+
+ describe('cleanupSession', () => {
+ it('should remove tracked data for session', () => {
+ recovery.recordLastPrompt('session-1', 'Some prompt');
+ recovery.cleanupSession('session-1');
+ expect(recovery.isRecovering('session-1')).toBe(false);
+ });
+ });
+});
diff --git a/src/__tests__/main/accounts/account-setup.test.ts b/src/__tests__/main/accounts/account-setup.test.ts
index eddb5b14d..6a404b5a7 100644
--- a/src/__tests__/main/accounts/account-setup.test.ts
+++ b/src/__tests__/main/accounts/account-setup.test.ts
@@ -153,13 +153,13 @@ describe('account-setup', () => {
expect(result.errors[0]).toContain('does not exist');
});
- it('should report missing .claude.json', async () => {
+ it('should report missing credentials files', async () => {
mockStat.mockResolvedValue({ isDirectory: () => true });
mockAccess.mockRejectedValue(new Error('ENOENT'));
const result = await validateBaseClaudeDir();
expect(result.valid).toBe(false);
- expect(result.errors).toContain('No .claude.json found — Claude Code may not be authenticated.');
+ expect(result.errors).toContain('No .credentials.json or .claude.json found — Claude Code may not be authenticated.');
});
});
diff --git a/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts b/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts
index 9caba0f65..0c063819f 100644
--- a/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts
+++ b/src/__tests__/renderer/hooks/useAgentErrorRecovery.test.ts
@@ -29,7 +29,7 @@ describe('useAgentErrorRecovery', () => {
const [authAction, newSessionAction] = result.current.recoveryActions;
expect(authAction.id).toBe('authenticate');
- expect(authAction.label).toBe('Use Terminal');
+ expect(authAction.label).toBe('Re-authenticate');
expect(authAction.primary).toBe(true);
expect(newSessionAction.id).toBe('new-session');
diff --git a/src/__tests__/renderer/stores/agentStore.test.ts b/src/__tests__/renderer/stores/agentStore.test.ts
index ec9496531..e52ce7f90 100644
--- a/src/__tests__/renderer/stores/agentStore.test.ts
+++ b/src/__tests__/renderer/stores/agentStore.test.ts
@@ -90,6 +90,7 @@ const mockInterrupt = vi.fn().mockResolvedValue(true);
const mockDetect = vi.fn().mockResolvedValue([]);
const mockGetAgent = vi.fn().mockResolvedValue(null);
const mockClearError = vi.fn().mockResolvedValue(undefined);
+const mockTriggerAuthRecovery = vi.fn().mockResolvedValue({ success: true });
(window as any).maestro = {
process: {
@@ -104,6 +105,9 @@ const mockClearError = vi.fn().mockResolvedValue(undefined);
agentError: {
clearError: mockClearError,
},
+ accounts: {
+ triggerAuthRecovery: mockTriggerAuthRecovery,
+ },
};
// Mock gitService
@@ -740,7 +744,7 @@ describe('agentStore', () => {
});
describe('authenticateAfterError', () => {
- it('clears error, sets active session, and switches to terminal mode', () => {
+ it('clears error and triggers auth recovery via IPC', () => {
const session = createMockSession({
id: 'session-1',
state: 'error',
@@ -754,22 +758,22 @@ describe('agentStore', () => {
const updated = useSessionStore.getState().sessions[0];
expect(updated.state).toBe('idle');
- expect(updated.inputMode).toBe('terminal');
expect(updated.agentError).toBeUndefined();
- expect(useSessionStore.getState().activeSessionId).toBe('session-1');
+ expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-1');
});
it('does nothing if session not found', () => {
useAgentStore.getState().authenticateAfterError('nonexistent');
// No crash, no IPC calls
expect(mockClearError).not.toHaveBeenCalled();
+ expect(mockTriggerAuthRecovery).not.toHaveBeenCalled();
});
- it('is idempotent when session is already in terminal mode', () => {
+ it('is idempotent on repeated calls', () => {
const session = createMockSession({
id: 'session-1',
state: 'error',
- inputMode: 'terminal',
+ inputMode: 'ai',
});
useSessionStore.getState().setSessions([session]);
@@ -778,10 +782,10 @@ describe('agentStore', () => {
const updated = useSessionStore.getState().sessions[0];
expect(updated.state).toBe('idle');
- expect(updated.inputMode).toBe('terminal');
+ expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-1');
});
- it('switches active session even if it was already active', () => {
+ it('triggers recovery regardless of current input mode', () => {
const session = createMockSession({
id: 'session-1',
state: 'error',
@@ -793,8 +797,7 @@ describe('agentStore', () => {
useAgentStore.getState().authenticateAfterError('session-1');
- expect(useSessionStore.getState().activeSessionId).toBe('session-1');
- expect(useSessionStore.getState().sessions[0].inputMode).toBe('terminal');
+ expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-1');
});
it('calls IPC clearError via delegation', () => {
@@ -1085,10 +1088,9 @@ describe('agentStore', () => {
useAgentStore.getState().authenticateAfterError('session-2');
- // Active session switched to session-2
- expect(useSessionStore.getState().activeSessionId).toBe('session-2');
- // session-2 is now in terminal mode
- expect(useSessionStore.getState().sessions[1].inputMode).toBe('terminal');
+ // session-2 error cleared, auth recovery triggered
+ expect(useSessionStore.getState().sessions[1].state).toBe('idle');
+ expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-2');
});
it('double clear is idempotent', () => {
@@ -1132,7 +1134,7 @@ describe('agentStore', () => {
expect(updated[0].state).toBe('idle');
expect(updated[0].agentError).toBeUndefined();
expect(updated[1].state).toBe('idle');
- expect(updated[1].inputMode).toBe('terminal');
+ expect(mockTriggerAuthRecovery).toHaveBeenCalledWith('session-2');
});
it('recovery after restart then new session', async () => {
diff --git a/src/main/accounts/account-auth-recovery.ts b/src/main/accounts/account-auth-recovery.ts
new file mode 100644
index 000000000..00b388427
--- /dev/null
+++ b/src/main/accounts/account-auth-recovery.ts
@@ -0,0 +1,282 @@
+/**
+ * Account Auth Recovery Service
+ *
+ * Orchestrates automatic re-authentication when an agent encounters
+ * an expired OAuth token:
+ * 1. Kills the failed agent process
+ * 2. Spawns `claude login` with the account's CLAUDE_CONFIG_DIR
+ * 3. Browser opens for OAuth — user clicks "Authorize"
+ * 4. Credentials are refreshed in the account directory
+ * 5. Sends respawn event to renderer (reuses account:switch-respawn channel)
+ *
+ * Fallback: if `claude login` fails, attempts to sync credentials
+ * from the base ~/.claude directory.
+ */
+
+import { spawn } from 'child_process';
+import * as fs from 'fs/promises';
+import * as path from 'path';
+import type { ProcessManager } from '../process-manager/ProcessManager';
+import type { AccountRegistry } from './account-registry';
+import type { AgentDetector } from '../agents';
+import type { SafeSendFn } from '../utils/safe-send';
+import { syncCredentialsFromBase } from './account-setup';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'account-auth-recovery';
+
+/** Timeout for `claude login` to complete (user must authorize in browser) */
+const LOGIN_TIMEOUT_MS = 120_000;
+
+/** Delay between killing old process and starting login (ms) */
+const KILL_DELAY_MS = 1000;
+
+/** Set of session IDs currently undergoing auth recovery (prevents double-fire) */
+const activeRecoveries = new Set();
+
+export class AccountAuthRecovery {
+ /** Tracks the last user prompt per session for re-sending after recovery */
+ private lastPrompts = new Map();
+
+ constructor(
+ private processManager: ProcessManager,
+ private accountRegistry: AccountRegistry,
+ private agentDetector: AgentDetector,
+ private safeSend: SafeSendFn,
+ ) {}
+
+ /**
+ * Record the last user prompt sent to a session.
+ * Called by the process write handler so we can re-send after recovery.
+ */
+ recordLastPrompt(sessionId: string, prompt: string): void {
+ this.lastPrompts.set(sessionId, prompt);
+ }
+
+ /**
+ * Check if a session is currently undergoing auth recovery.
+ */
+ isRecovering(sessionId: string): boolean {
+ return activeRecoveries.has(sessionId);
+ }
+
+ /**
+ * Main entry point: recover authentication for a session.
+ *
+ * @param sessionId - The session that hit an auth error
+ * @param accountId - The account assigned to that session
+ * @returns true if recovery succeeded and respawn was triggered
+ */
+ async recoverAuth(sessionId: string, accountId: string): Promise {
+ // Prevent double-fire if error listener fires multiple times
+ if (activeRecoveries.has(sessionId)) {
+ logger.warn('Auth recovery already in progress for session', LOG_CONTEXT, { sessionId });
+ return false;
+ }
+
+ activeRecoveries.add(sessionId);
+
+ try {
+ const account = this.accountRegistry.get(accountId);
+ if (!account) {
+ logger.error('Account not found for auth recovery', LOG_CONTEXT, { accountId });
+ return false;
+ }
+
+ logger.info(`Starting auth recovery for account ${account.name}`, LOG_CONTEXT, {
+ sessionId, accountId, configDir: account.configDir,
+ });
+
+ // 1. Mark account as expired
+ this.accountRegistry.setStatus(accountId, 'expired');
+
+ // 2. Kill the current agent process
+ const killed = this.processManager.kill(sessionId);
+ if (!killed) {
+ logger.warn('Could not kill process (may have already exited)', LOG_CONTEXT, { sessionId });
+ }
+
+ // 3. Notify renderer that recovery is starting
+ this.safeSend('account:auth-recovery-started', {
+ sessionId,
+ accountId,
+ accountName: account.name,
+ });
+
+ // Wait for process cleanup
+ await new Promise(resolve => setTimeout(resolve, KILL_DELAY_MS));
+
+ // 4. Attempt `claude login`
+ const loginSuccess = await this.runClaudeLogin(account.configDir);
+
+ if (loginSuccess) {
+ return this.handleLoginSuccess(sessionId, accountId, account.configDir, account.name);
+ }
+
+ // 5. Fallback: sync credentials from base ~/.claude directory
+ logger.info('Login failed, attempting credential sync from base dir', LOG_CONTEXT);
+ const syncResult = await syncCredentialsFromBase(account.configDir);
+
+ if (syncResult.success) {
+ logger.info('Credential sync from base succeeded', LOG_CONTEXT);
+ return this.handleLoginSuccess(sessionId, accountId, account.configDir, account.name);
+ }
+
+ // 6. All recovery failed
+ logger.error('All auth recovery methods failed', LOG_CONTEXT, {
+ sessionId, accountId, syncError: syncResult.error,
+ });
+
+ this.safeSend('account:auth-recovery-failed', {
+ sessionId,
+ accountId,
+ accountName: account.name,
+ error: 'Authentication failed. Please run "claude login" manually in a terminal.',
+ });
+
+ return false;
+
+ } catch (error) {
+ logger.error('Auth recovery threw unexpectedly', LOG_CONTEXT, {
+ error: String(error), sessionId, accountId,
+ });
+
+ this.safeSend('account:auth-recovery-failed', {
+ sessionId,
+ accountId,
+ error: String(error),
+ });
+
+ return false;
+ } finally {
+ activeRecoveries.delete(sessionId);
+ }
+ }
+
+ /**
+ * Handle successful credential refresh: mark active, send respawn event.
+ */
+ private handleLoginSuccess(
+ sessionId: string,
+ accountId: string,
+ configDir: string,
+ accountName: string,
+ ): boolean {
+ // Mark account as active again
+ this.accountRegistry.setStatus(accountId, 'active');
+
+ const lastPrompt = this.lastPrompts.get(sessionId);
+
+ // Notify renderer that recovery completed
+ this.safeSend('account:auth-recovery-completed', {
+ sessionId,
+ accountId,
+ accountName,
+ });
+
+ // Reuse the switch-respawn channel — renderer already handles it
+ this.safeSend('account:switch-respawn', {
+ sessionId,
+ toAccountId: accountId,
+ toAccountName: accountName,
+ configDir,
+ lastPrompt: lastPrompt ?? null,
+ reason: 'auth-recovery',
+ });
+
+ logger.info(`Auth recovery completed for account ${accountName}`, LOG_CONTEXT, {
+ sessionId, accountId,
+ });
+
+ return true;
+ }
+
+ /**
+ * Spawn `claude login` with the account's CLAUDE_CONFIG_DIR.
+ * Opens a browser for OAuth. Returns true if login exited successfully.
+ */
+ private async runClaudeLogin(configDir: string): Promise {
+ // Resolve the claude binary path
+ const agent = await this.agentDetector.getAgent('claude-code');
+ const claudeBinary = agent?.path ?? agent?.command ?? 'claude';
+
+ logger.info(`Spawning claude login with binary: ${claudeBinary}`, LOG_CONTEXT, { configDir });
+
+ return new Promise((resolve) => {
+ const child = spawn(claudeBinary, ['login'], {
+ env: {
+ ...process.env,
+ CLAUDE_CONFIG_DIR: configDir,
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout?.on('data', (data) => {
+ stdout += data.toString();
+ logger.debug(`claude login stdout: ${data.toString().trim()}`, LOG_CONTEXT);
+ });
+
+ child.stderr?.on('data', (data) => {
+ stderr += data.toString();
+ logger.debug(`claude login stderr: ${data.toString().trim()}`, LOG_CONTEXT);
+ });
+
+ // Timeout: if user doesn't authorize in time
+ const timeout = setTimeout(() => {
+ logger.warn('claude login timed out', LOG_CONTEXT, { configDir });
+ child.kill('SIGTERM');
+ resolve(false);
+ }, LOGIN_TIMEOUT_MS);
+
+ child.on('close', async (code) => {
+ clearTimeout(timeout);
+
+ if (code === 0) {
+ // Verify credentials were actually written
+ const credsExist = await this.verifyCredentials(configDir);
+ if (credsExist) {
+ logger.info('claude login succeeded', LOG_CONTEXT, { configDir });
+ resolve(true);
+ } else {
+ logger.warn('claude login exited 0 but no credentials found', LOG_CONTEXT);
+ resolve(false);
+ }
+ } else {
+ logger.warn(`claude login exited with code ${code}`, LOG_CONTEXT, {
+ stderr: stderr.slice(0, 500),
+ });
+ resolve(false);
+ }
+ });
+
+ child.on('error', (err) => {
+ clearTimeout(timeout);
+ logger.error(`claude login spawn error: ${err.message}`, LOG_CONTEXT);
+ resolve(false);
+ });
+ });
+ }
+
+ /**
+ * Verify that .credentials.json exists in the account directory
+ * after a login attempt.
+ */
+ private async verifyCredentials(configDir: string): Promise {
+ try {
+ const credPath = path.join(configDir, '.credentials.json');
+ await fs.access(credPath);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /** Clean up tracking data when a session is closed */
+ cleanupSession(sessionId: string): void {
+ this.lastPrompts.delete(sessionId);
+ activeRecoveries.delete(sessionId);
+ }
+}
diff --git a/src/main/accounts/account-setup.ts b/src/main/accounts/account-setup.ts
index 31c4dd360..c29f98b52 100644
--- a/src/main/accounts/account-setup.ts
+++ b/src/main/accounts/account-setup.ts
@@ -41,11 +41,15 @@ export async function validateBaseClaudeDir(): Promise<{
errors.push(`${baseDir} does not exist. Run 'claude' at least once to create it.`);
}
- // Check for .claude.json (auth tokens)
+ // Check for auth tokens — Claude Code uses .credentials.json (current) or .claude.json (legacy)
try {
- await fs.access(path.join(baseDir, '.claude.json'));
+ await fs.access(path.join(baseDir, '.credentials.json'));
} catch {
- errors.push('No .claude.json found — Claude Code may not be authenticated.');
+ try {
+ await fs.access(path.join(baseDir, '.claude.json'));
+ } catch {
+ errors.push('No .credentials.json or .claude.json found — Claude Code may not be authenticated.');
+ }
}
return { valid: errors.length === 0, baseDir, errors };
@@ -98,9 +102,11 @@ function extractEmailFromClaudeJson(content: string): string | null {
try {
const json = JSON.parse(content);
// Try common field names where email might be stored
+ // Claude Code stores it at oauthAccount.emailAddress
return json.email
|| json.accountEmail
|| json.primaryEmail
+ || json.oauthAccount?.emailAddress
|| json.oauthAccount?.email
|| json.account?.email
|| null;
@@ -252,6 +258,51 @@ export async function repairAccountSymlinks(configDir: string): Promise<{
return { repaired, errors };
}
+/**
+ * Sync credentials from the base ~/.claude directory to an account directory.
+ * Used after the user runs `claude login` in the base dir to propagate
+ * fresh OAuth tokens to the account directory.
+ *
+ * Copies .credentials.json from ~/.claude to the target configDir.
+ */
+export async function syncCredentialsFromBase(configDir: string): Promise<{
+ success: boolean;
+ error?: string;
+}> {
+ const baseDir = path.join(os.homedir(), '.claude');
+ const baseCreds = path.join(baseDir, '.credentials.json');
+ const targetCreds = path.join(configDir, '.credentials.json');
+
+ try {
+ // Verify base credentials exist
+ try {
+ await fs.access(baseCreds);
+ } catch {
+ return { success: false, error: 'No .credentials.json found in base ~/.claude directory' };
+ }
+
+ // Verify target directory exists
+ try {
+ const stat = await fs.stat(configDir);
+ if (!stat.isDirectory()) {
+ return { success: false, error: `${configDir} is not a directory` };
+ }
+ } catch {
+ return { success: false, error: `${configDir} does not exist` };
+ }
+
+ // Copy the credentials
+ const content = await fs.readFile(baseCreds, 'utf-8');
+ await fs.writeFile(targetCreds, content, 'utf-8');
+
+ logger.info(`Synced credentials from ${baseCreds} to ${targetCreds}`, LOG_CONTEXT);
+ return { success: true };
+ } catch (error) {
+ logger.error('Failed to sync credentials', LOG_CONTEXT, { error: String(error) });
+ return { success: false, error: String(error) };
+ }
+}
+
/**
* Build the command string to launch `claude login` for a specific account.
* This should be run in a Maestro terminal session.
diff --git a/src/main/process-listeners/account-usage-listener.ts b/src/main/process-listeners/account-usage-listener.ts
index 7d6a02af4..d2ee3a3aa 100644
--- a/src/main/process-listeners/account-usage-listener.ts
+++ b/src/main/process-listeners/account-usage-listener.ts
@@ -9,25 +9,10 @@ import type { AccountRegistry } from '../accounts/account-registry';
import type { StatsDB } from '../stats';
import type { UsageStats } from './types';
import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types';
+import { getWindowBounds } from '../accounts/account-utils';
const LOG_CONTEXT = 'account-usage-listener';
-/**
- * Calculate the window boundaries for a given timestamp and window size.
- * Windows are aligned to fixed intervals (e.g., every 5 hours from midnight).
- */
-function getWindowBounds(timestamp: number, windowMs: number): { start: number; end: number } {
- // Align windows to midnight of the current day
- const dayStart = new Date(timestamp);
- dayStart.setHours(0, 0, 0, 0);
- const dayStartMs = dayStart.getTime();
-
- const windowsSinceDayStart = Math.floor((timestamp - dayStartMs) / windowMs);
- const start = dayStartMs + windowsSinceDayStart * windowMs;
- const end = start + windowMs;
- return { start, end };
-}
-
/**
* Sets up the account usage listener that aggregates per-session usage events
* into per-account usage windows for limit tracking and prediction.
diff --git a/src/main/process-listeners/error-listener.ts b/src/main/process-listeners/error-listener.ts
index 0c2bc7e86..5094aecdb 100644
--- a/src/main/process-listeners/error-listener.ts
+++ b/src/main/process-listeners/error-listener.ts
@@ -1,20 +1,22 @@
/**
* Agent error listener.
* Handles agent errors (auth expired, token exhaustion, rate limits, etc.).
- * When account multiplexing is active, triggers throttle handling for
- * rate_limited and auth_expired errors on sessions with account assignments.
+ * When account multiplexing is active:
+ * - rate_limited errors → throttle handler (account switching)
+ * - auth_expired errors → auth recovery (re-login + respawn)
*/
import type { ProcessManager } from '../process-manager';
import type { AgentError } from '../../shared/types';
import type { ProcessListenerDependencies } from './types';
import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
+import type { AccountAuthRecovery } from '../accounts/account-auth-recovery';
import type { AccountRegistry } from '../accounts/account-registry';
/**
* Sets up the agent-error listener.
* Handles logging and forwarding of agent errors to renderer.
- * Optionally triggers throttle handling for account multiplexing.
+ * Optionally triggers throttle handling or auth recovery for account multiplexing.
*/
export function setupErrorListener(
processManager: ProcessManager,
@@ -22,6 +24,7 @@ export function setupErrorListener(
accountDeps?: {
getAccountRegistry: () => AccountRegistry | null;
getThrottleHandler: () => AccountThrottleHandler | null;
+ getAuthRecovery: () => AccountAuthRecovery | null;
}
): void {
const { safeSend, logger } = deps;
@@ -37,20 +40,34 @@ export function setupErrorListener(
});
safeSend('agent:error', sessionId, agentError);
- // Trigger throttle handling for rate-limited/auth-expired errors on sessions with accounts
- if (accountDeps && (agentError.type === 'rate_limited' || agentError.type === 'auth_expired')) {
- const accountRegistry = accountDeps.getAccountRegistry();
- const throttleHandler = accountDeps.getThrottleHandler();
- if (accountRegistry && throttleHandler) {
- const assignment = accountRegistry.getAssignment(sessionId);
- if (assignment) {
- throttleHandler.handleThrottle({
- sessionId,
- accountId: assignment.accountId,
- errorType: agentError.type,
- errorMessage: agentError.message,
+ if (!accountDeps) return;
+
+ const accountRegistry = accountDeps.getAccountRegistry();
+ if (!accountRegistry) return;
+
+ const assignment = accountRegistry.getAssignment(sessionId);
+ if (!assignment) return;
+
+ if (agentError.type === 'auth_expired') {
+ // Auth expired → attempt automatic re-login
+ const authRecovery = accountDeps.getAuthRecovery();
+ if (authRecovery) {
+ authRecovery.recoverAuth(sessionId, assignment.accountId).catch((err) => {
+ logger.error('Auth recovery failed', 'AgentError', {
+ error: String(err), sessionId,
});
- }
+ });
+ }
+ } else if (agentError.type === 'rate_limited') {
+ // Rate limited → throttle handler (account switching)
+ const throttleHandler = accountDeps.getThrottleHandler();
+ if (throttleHandler) {
+ throttleHandler.handleThrottle({
+ sessionId,
+ accountId: assignment.accountId,
+ errorType: agentError.type,
+ errorMessage: agentError.message,
+ });
}
}
});
diff --git a/src/main/process-listeners/index.ts b/src/main/process-listeners/index.ts
index c35bd25b5..d2eaa7b94 100644
--- a/src/main/process-listeners/index.ts
+++ b/src/main/process-listeners/index.ts
@@ -45,10 +45,11 @@ export function setupProcessListeners(
// Session ID listener (with group chat participant/moderator storage)
setupSessionIdListener(processManager, deps);
- // Agent error listener (with optional account throttle handling)
+ // Agent error listener (with optional account throttle/auth recovery handling)
setupErrorListener(processManager, deps, deps.getAccountRegistry ? {
getAccountRegistry: deps.getAccountRegistry,
getThrottleHandler: deps.getThrottleHandler ?? (() => null),
+ getAuthRecovery: deps.getAuthRecovery ?? (() => null),
} : undefined);
// Stats/query-complete listener
diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts
index f6439b2c2..7e2ae1356 100644
--- a/src/main/process-listeners/types.ts
+++ b/src/main/process-listeners/types.ts
@@ -10,6 +10,7 @@ import type { SafeSendFn } from '../utils/safe-send';
import type { StatsDB } from '../stats';
import type { AccountRegistry } from '../accounts/account-registry';
import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
+import type { AccountAuthRecovery } from '../accounts/account-auth-recovery';
import type { GroupChat, GroupChatParticipant } from '../group-chat/group-chat-storage';
import type { GroupChatMessage, GroupChatState } from '../../shared/group-chat-types';
import type { ParticipantState } from '../ipc/handlers/groupChat';
@@ -149,6 +150,8 @@ export interface ProcessListenerDependencies {
getAccountRegistry?: () => AccountRegistry | null;
/** Account throttle handler getter (optional — only needed for account multiplexing) */
getThrottleHandler?: () => AccountThrottleHandler | null;
+ /** Account auth recovery getter (optional — only needed for account multiplexing) */
+ getAuthRecovery?: () => AccountAuthRecovery | null;
/** Debug log function */
debugLog: (prefix: string, message: string, ...args: unknown[]) => void;
/** Regex patterns */
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index e4a059e9c..943ef89fe 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -375,7 +375,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
Registered Virtuosos
- AI Account Providers
+ AI Provider Accounts
void;
theme: any;
session: Session | null;
@@ -1211,6 +1213,8 @@ export function EditAgentModal({
const [_customModel, setCustomModel] = useState('');
const [refreshingAgent, setRefreshingAgent] = useState(false);
const [copiedId, setCopiedId] = useState(false);
+ // Account multiplexing
+ const [selectedAccountId, setSelectedAccountId] = useState(undefined);
// SSH Remote configuration
const [sshRemotes, setSshRemotes] = useState([]);
const [sshRemoteConfig, setSshRemoteConfig] = useState(
@@ -1292,6 +1296,9 @@ export function EditAgentModal({
setCustomArgs(session.customArgs ?? '');
setCustomEnvVars(session.customEnvVars ?? {});
setCustomModel(session.customModel ?? '');
+
+ // Load account assignment
+ setSelectedAccountId(session.accountId);
}
}, [isOpen, session]);
@@ -1423,7 +1430,8 @@ export function EditAgentModal({
Object.keys(customEnvVars).length > 0 ? customEnvVars : undefined,
modelValue,
contextWindowValue,
- sessionSshRemoteConfig
+ sessionSshRemoteConfig,
+ selectedAccountId || undefined,
);
onClose();
}, [
@@ -1435,6 +1443,7 @@ export function EditAgentModal({
customEnvVars,
agentConfig,
sshRemoteConfig,
+ selectedAccountId,
onSave,
onClose,
existingSessions,
@@ -1469,6 +1478,12 @@ export function EditAgentModal({
}
}, [session]);
+ // Handle account selection in edit modal (local state only — actual switch happens on save)
+ const handleSwitchAccount = useCallback((toAccountId: string) => {
+ if (!session || toAccountId === selectedAccountId) return;
+ setSelectedAccountId(toAccountId);
+ }, [session, selectedAccountId]);
+
// Check if form is valid for submission
const isFormValid = useMemo(() => {
// Remote path validation is informational only - don't block save
@@ -1651,6 +1666,28 @@ export function EditAgentModal({
)}
+ {/* Account Selector (Claude Code only) */}
+ {session.toolType === 'claude-code' && (
+
+
+ Account
+
+
+
+ Claude account used for this agent. Changing takes effect on next message.
+
+
+ )}
+
{/* Nudge Message */}
- AI Account Providers
+ AI Provider Accounts
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
index e7eca780c..1651e1412 100644
--- a/src/renderer/components/VirtuososModal.tsx
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -33,7 +33,7 @@ export function VirtuososModal({ isOpen, onClose, theme }: VirtuososModalProps)
>
- AI Account Providers
+ AI Provider Accounts
diff --git a/src/renderer/hooks/agent/useAgentErrorRecovery.tsx b/src/renderer/hooks/agent/useAgentErrorRecovery.tsx
index f86241ff9..66df117e6 100644
--- a/src/renderer/hooks/agent/useAgentErrorRecovery.tsx
+++ b/src/renderer/hooks/agent/useAgentErrorRecovery.tsx
@@ -19,7 +19,7 @@
*/
import { useMemo, useCallback } from 'react';
-import { KeyRound, MessageSquarePlus, RefreshCw, RotateCcw, Wifi, Terminal } from 'lucide-react';
+import { KeyRound, MessageSquarePlus, RefreshCw, RotateCcw, Wifi } from 'lucide-react';
import type { AgentError, ToolType } from '../../types';
import type { RecoveryAction } from '../../components/AgentErrorModal';
@@ -63,17 +63,14 @@ function getRecoveryActionsForError(
switch (error.type) {
case 'auth_expired':
- // Authentication error - offer to re-authenticate or start new session
+ // Authentication error - trigger automatic re-login
if (options.onAuthenticate) {
- const isClaude = agentId === 'claude-code';
actions.push({
id: 'authenticate',
- label: isClaude ? 'Use Terminal' : 'Re-authenticate',
- description: isClaude
- ? 'Run "claude login" in terminal'
- : 'Log in again to restore access',
+ label: 'Re-authenticate',
+ description: 'Opens browser to re-authorize access',
primary: true,
- icon: isClaude ?
:
,
+ icon:
,
onClick: options.onAuthenticate,
});
}
diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts
index 4c99e0cce..9621738b0 100644
--- a/src/renderer/stores/agentStore.ts
+++ b/src/renderer/stores/agentStore.ts
@@ -224,9 +224,11 @@ export const useAgentStore = create
()((set, get) => ({
get().clearAgentError(sessionId);
- // Switch to terminal mode for re-auth
- useSessionStore.getState().setActiveSessionId(sessionId);
- updateSession(sessionId, (s) => ({ ...s, inputMode: 'terminal' }));
+ // Trigger automatic auth recovery via main process
+ // This will spawn `claude login`, open browser for OAuth, and respawn the agent
+ window.maestro.accounts.triggerAuthRecovery(sessionId).catch((err) => {
+ console.error('[authenticateAfterError] Auth recovery failed:', err);
+ });
},
processQueuedItem: async (sessionId, item, deps) => {
From ce2dcbea313315810d8123c04f4596d3ef2789e2 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 20:51:29 -0500
Subject: [PATCH 25/59] MAESTRO: refactor: code quality hardening for Virtuoso
feature
Replace blocking alert() calls with toast notifications in AccountsPanel,
add console.warn to 5 silent catch blocks for Sentry breadcrumb visibility,
replace all hardcoded color hex values with theme.colors.error/warning,
tighten 7 loosely-typed Record event handlers in global.d.ts
with specific payload interfaces, and expand test coverage for AccountSelector
(8 tests) and AccountSwitchModal (9 tests).
Co-Authored-By: Claude Opus 4.6
---
.../components/AccountSelector.test.ts | 14 -
.../components/AccountSelector.test.tsx | 429 ++++++++++++++++++
.../components/AccountSwitchModal.test.ts | 53 ---
.../components/AccountSwitchModal.test.tsx | 210 +++++++++
src/renderer/App.tsx | 2 +-
src/renderer/components/AccountSelector.tsx | 8 +-
.../components/AccountUsageHistory.tsx | 4 +-
src/renderer/components/AccountsPanel.tsx | 34 +-
src/renderer/global.d.ts | 14 +-
src/renderer/hooks/useAccountUsage.ts | 7 +-
10 files changed, 676 insertions(+), 99 deletions(-)
delete mode 100644 src/__tests__/renderer/components/AccountSelector.test.ts
create mode 100644 src/__tests__/renderer/components/AccountSelector.test.tsx
delete mode 100644 src/__tests__/renderer/components/AccountSwitchModal.test.ts
create mode 100644 src/__tests__/renderer/components/AccountSwitchModal.test.tsx
diff --git a/src/__tests__/renderer/components/AccountSelector.test.ts b/src/__tests__/renderer/components/AccountSelector.test.ts
deleted file mode 100644
index 231ce50ea..000000000
--- a/src/__tests__/renderer/components/AccountSelector.test.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * @file AccountSelector.test.ts
- * @description Tests for AccountSelector component exports
- */
-
-import { describe, it, expect } from 'vitest';
-
-describe('AccountSelector', () => {
- it('should export the component', async () => {
- const mod = await import('../../../renderer/components/AccountSelector');
- expect(mod.AccountSelector).toBeDefined();
- expect(typeof mod.AccountSelector).toBe('function');
- });
-});
diff --git a/src/__tests__/renderer/components/AccountSelector.test.tsx b/src/__tests__/renderer/components/AccountSelector.test.tsx
new file mode 100644
index 000000000..fa876c7cf
--- /dev/null
+++ b/src/__tests__/renderer/components/AccountSelector.test.tsx
@@ -0,0 +1,429 @@
+/**
+ * @file AccountSelector.test.tsx
+ * @description Tests for AccountSelector component
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import type { Theme } from '../../../shared/theme-types';
+import type { AccountProfile } from '../../../shared/account-types';
+
+// Mock useAccountUsage before importing the component
+vi.mock('../../../renderer/hooks/useAccountUsage', () => ({
+ useAccountUsage: vi.fn().mockReturnValue({
+ metrics: {},
+ loading: false,
+ refresh: vi.fn(),
+ }),
+ formatTimeRemaining: vi.fn((ms: number) => ms > 0 ? '1h 30m' : '—'),
+ formatTokenCount: vi.fn((tokens: number) => {
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
+ return String(tokens);
+ }),
+}));
+
+import { AccountSelector } from '../../../renderer/components/AccountSelector';
+import { useAccountUsage } from '../../../renderer/hooks/useAccountUsage';
+
+const mockTheme: Theme = {
+ id: 'dracula',
+ name: 'Dracula',
+ mode: 'dark',
+ colors: {
+ bgMain: '#282a36',
+ bgSidebar: '#21222c',
+ bgActivity: '#44475a',
+ border: '#6272a4',
+ textMain: '#f8f8f2',
+ textDim: '#6272a4',
+ accent: '#bd93f9',
+ accentDim: '#bd93f920',
+ accentText: '#bd93f9',
+ accentForeground: '#ffffff',
+ success: '#50fa7b',
+ warning: '#f1fa8c',
+ error: '#ff5555',
+ },
+};
+
+const mockAccounts: AccountProfile[] = [
+ {
+ id: 'acc-1',
+ name: 'work-account',
+ email: 'work@example.com',
+ configDir: '/home/user/.claude-work',
+ isDefault: true,
+ status: 'active',
+ autoSwitchEnabled: true,
+ tokenLimitPerWindow: 19000,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ createdAt: Date.now(),
+ },
+ {
+ id: 'acc-2',
+ name: 'personal-account',
+ email: 'personal@example.com',
+ configDir: '/home/user/.claude-personal',
+ isDefault: false,
+ status: 'active',
+ autoSwitchEnabled: false,
+ tokenLimitPerWindow: 88000,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ createdAt: Date.now(),
+ },
+ {
+ id: 'acc-3',
+ name: 'team-account',
+ email: 'team@example.com',
+ configDir: '/home/user/.claude-team',
+ isDefault: false,
+ status: 'throttled',
+ autoSwitchEnabled: true,
+ tokenLimitPerWindow: 220000,
+ tokenWindowMs: 5 * 60 * 60 * 1000,
+ createdAt: Date.now(),
+ },
+];
+
+describe('AccountSelector', () => {
+ let onSwitchAccount: ReturnType;
+ let onManageAccounts: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ onSwitchAccount = vi.fn();
+ onManageAccounts = vi.fn();
+ vi.mocked(window.maestro.accounts.list).mockResolvedValue(mockAccounts);
+ vi.mocked(useAccountUsage).mockReturnValue({
+ metrics: {},
+ loading: false,
+ refresh: vi.fn(),
+ });
+ });
+
+ it('should export the component', async () => {
+ const mod = await import('../../../renderer/components/AccountSelector');
+ expect(mod.AccountSelector).toBeDefined();
+ expect(typeof mod.AccountSelector).toBe('function');
+ });
+
+ it('should render compact mode with abbreviated account name', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ // After accounts load, displayName becomes account.name ("work-account")
+ // split('@')[0] on "work-account" yields "work-account"
+ await waitFor(() => {
+ expect(screen.getByText('work-account')).toBeInTheDocument();
+ });
+ });
+
+ it('should show dropdown with all accounts on click', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ // Wait for accounts to load
+ await waitFor(() => {
+ expect(window.maestro.accounts.list).toHaveBeenCalled();
+ });
+
+ // Click the selector button to open dropdown
+ const trigger = screen.getByRole('button');
+ await act(async () => {
+ fireEvent.click(trigger);
+ });
+
+ // All 3 accounts should be visible in the dropdown
+ // work-account may appear in both trigger and dropdown, so use getAllByText
+ await waitFor(() => {
+ expect(screen.getAllByText('work-account').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('personal-account')).toBeInTheDocument();
+ expect(screen.getByText('team-account')).toBeInTheDocument();
+ });
+ });
+
+ it('should show usage bars with correct theme colors', async () => {
+ vi.mocked(useAccountUsage).mockReturnValue({
+ metrics: {
+ 'acc-1': {
+ accountId: 'acc-1',
+ totalTokens: 9500,
+ limitTokens: 19000,
+ usagePercent: 50,
+ costUsd: 1.50,
+ queryCount: 10,
+ windowStart: Date.now() - 1000000,
+ windowEnd: Date.now() + 1000000,
+ timeRemainingMs: 1000000,
+ burnRatePerHour: 5000,
+ estimatedTimeToLimitMs: 2000000,
+ status: 'active',
+ prediction: {
+ linearTimeToLimitMs: null,
+ weightedTimeToLimitMs: null,
+ p90TokensPerWindow: 0,
+ avgTokensPerWindow: 0,
+ confidence: 'low',
+ windowsRemainingP90: null,
+ },
+ },
+ 'acc-2': {
+ accountId: 'acc-2',
+ totalTokens: 74800,
+ limitTokens: 88000,
+ usagePercent: 85,
+ costUsd: 12.00,
+ queryCount: 50,
+ windowStart: Date.now() - 1000000,
+ windowEnd: Date.now() + 1000000,
+ timeRemainingMs: 1000000,
+ burnRatePerHour: 40000,
+ estimatedTimeToLimitMs: 500000,
+ status: 'active',
+ prediction: {
+ linearTimeToLimitMs: null,
+ weightedTimeToLimitMs: null,
+ p90TokensPerWindow: 0,
+ avgTokensPerWindow: 0,
+ confidence: 'low',
+ windowsRemainingP90: null,
+ },
+ },
+ 'acc-3': {
+ accountId: 'acc-3',
+ totalTokens: 211200,
+ limitTokens: 220000,
+ usagePercent: 96,
+ costUsd: 30.00,
+ queryCount: 100,
+ windowStart: Date.now() - 1000000,
+ windowEnd: Date.now() + 1000000,
+ timeRemainingMs: 1000000,
+ burnRatePerHour: 100000,
+ estimatedTimeToLimitMs: 100000,
+ status: 'throttled',
+ prediction: {
+ linearTimeToLimitMs: null,
+ weightedTimeToLimitMs: null,
+ p90TokensPerWindow: 0,
+ avgTokensPerWindow: 0,
+ confidence: 'low',
+ windowsRemainingP90: null,
+ },
+ },
+ },
+ loading: false,
+ refresh: vi.fn(),
+ });
+
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(window.maestro.accounts.list).toHaveBeenCalled();
+ });
+
+ // Open dropdown
+ const trigger = screen.getByRole('button');
+ await act(async () => {
+ fireEvent.click(trigger);
+ });
+
+ // Verify accounts are rendered with usage bars
+ // work-account may appear in both trigger and dropdown, so use getAllByText
+ await waitFor(() => {
+ expect(screen.getAllByText('work-account').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('personal-account')).toBeInTheDocument();
+ expect(screen.getByText('team-account')).toBeInTheDocument();
+ });
+ });
+
+ it('should call onSwitchAccount when different account selected', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(window.maestro.accounts.list).toHaveBeenCalled();
+ });
+
+ // Open dropdown
+ const trigger = screen.getByRole('button');
+ await act(async () => {
+ fireEvent.click(trigger);
+ });
+
+ // Click account B
+ await waitFor(() => {
+ expect(screen.getByText('personal-account')).toBeInTheDocument();
+ });
+ await act(async () => {
+ fireEvent.click(screen.getByText('personal-account'));
+ });
+
+ expect(onSwitchAccount).toHaveBeenCalledWith('acc-2');
+ });
+
+ it('should show "Manage Virtuosos" footer linking to VirtuososModal', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(window.maestro.accounts.list).toHaveBeenCalled();
+ });
+
+ // Open dropdown
+ const trigger = screen.getByRole('button');
+ await act(async () => {
+ fireEvent.click(trigger);
+ });
+
+ // Assert "Manage Virtuosos" item present
+ await waitFor(() => {
+ expect(screen.getByText('Manage Virtuosos')).toBeInTheDocument();
+ });
+
+ // Click it
+ await act(async () => {
+ fireEvent.click(screen.getByText('Manage Virtuosos'));
+ });
+
+ expect(onManageAccounts).toHaveBeenCalled();
+ });
+
+ it('should close dropdown on Escape key', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(window.maestro.accounts.list).toHaveBeenCalled();
+ });
+
+ // Open dropdown
+ const trigger = screen.getByRole('button');
+ await act(async () => {
+ fireEvent.click(trigger);
+ });
+
+ // Verify dropdown is open
+ await waitFor(() => {
+ expect(screen.getByText('Manage Virtuosos')).toBeInTheDocument();
+ });
+
+ // Press Escape
+ await act(async () => {
+ fireEvent.keyDown(document, { key: 'Escape' });
+ });
+
+ // Dropdown should be closed
+ await waitFor(() => {
+ expect(screen.queryByText('Manage Virtuosos')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should close dropdown on click outside', async () => {
+ await act(async () => {
+ render(
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(window.maestro.accounts.list).toHaveBeenCalled();
+ });
+
+ // Open dropdown
+ const triggers = screen.getAllByRole('button');
+ const selectorTrigger = triggers.find(
+ (btn) => btn.textContent?.includes('work')
+ ) ?? triggers[0];
+ await act(async () => {
+ fireEvent.click(selectorTrigger);
+ });
+
+ // Verify dropdown is open
+ await waitFor(() => {
+ expect(screen.getByText('Manage Virtuosos')).toBeInTheDocument();
+ });
+
+ // Click outside
+ await act(async () => {
+ fireEvent.mouseDown(screen.getByTestId('outside'));
+ });
+
+ // Dropdown should be closed
+ await waitFor(() => {
+ expect(screen.queryByText('Manage Virtuosos')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/__tests__/renderer/components/AccountSwitchModal.test.ts b/src/__tests__/renderer/components/AccountSwitchModal.test.ts
deleted file mode 100644
index 3df95fc9d..000000000
--- a/src/__tests__/renderer/components/AccountSwitchModal.test.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-/**
- * @file AccountSwitchModal.test.ts
- * @description Tests for AccountSwitchModal component exports and types
- */
-
-import { describe, it, expect } from 'vitest';
-
-describe('AccountSwitchModal', () => {
- it('should export the component', async () => {
- const mod = await import('../../../renderer/components/AccountSwitchModal');
- expect(mod.AccountSwitchModal).toBeDefined();
- expect(typeof mod.AccountSwitchModal).toBe('function');
- });
-
- it('should return null when isOpen is false', async () => {
- const mod = await import('../../../renderer/components/AccountSwitchModal');
- const result = mod.AccountSwitchModal({
- theme: {
- id: 'dracula',
- name: 'Dracula',
- mode: 'dark',
- colors: {
- bgMain: '#282a36',
- bgSidebar: '#21222c',
- bgActivity: '#44475a',
- border: '#6272a4',
- textMain: '#f8f8f2',
- textDim: '#6272a4',
- accent: '#bd93f9',
- accentDim: '#bd93f920',
- accentText: '#bd93f9',
- accentForeground: '#ffffff',
- success: '#50fa7b',
- warning: '#f1fa8c',
- error: '#ff5555',
- },
- },
- isOpen: false,
- onClose: () => {},
- switchData: {
- sessionId: 'test',
- fromAccountId: 'a1',
- fromAccountName: 'Account 1',
- toAccountId: 'a2',
- toAccountName: 'Account 2',
- reason: 'throttled',
- },
- onConfirmSwitch: () => {},
- onViewDashboard: () => {},
- });
- expect(result).toBeNull();
- });
-});
diff --git a/src/__tests__/renderer/components/AccountSwitchModal.test.tsx b/src/__tests__/renderer/components/AccountSwitchModal.test.tsx
new file mode 100644
index 000000000..670607ee7
--- /dev/null
+++ b/src/__tests__/renderer/components/AccountSwitchModal.test.tsx
@@ -0,0 +1,210 @@
+/**
+ * @file AccountSwitchModal.test.tsx
+ * @description Tests for AccountSwitchModal component
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { LayerStackProvider } from '../../../renderer/contexts/LayerStackContext';
+import { AccountSwitchModal } from '../../../renderer/components/AccountSwitchModal';
+import type { Theme } from '../../../shared/theme-types';
+
+const mockTheme: Theme = {
+ id: 'dracula',
+ name: 'Dracula',
+ mode: 'dark',
+ colors: {
+ bgMain: '#282a36',
+ bgSidebar: '#21222c',
+ bgActivity: '#44475a',
+ border: '#6272a4',
+ textMain: '#f8f8f2',
+ textDim: '#6272a4',
+ accent: '#bd93f9',
+ accentDim: '#bd93f920',
+ accentText: '#bd93f9',
+ accentForeground: '#ffffff',
+ success: '#50fa7b',
+ warning: '#f1fa8c',
+ error: '#ff5555',
+ },
+};
+
+const renderWithLayerStack = (ui: React.ReactElement) => {
+ return render({ui} );
+};
+
+const baseSwitchData = {
+ sessionId: 'session-1',
+ fromAccountId: 'acc-1',
+ fromAccountName: 'Account 1',
+ toAccountId: 'acc-2',
+ toAccountName: 'Account 2',
+ reason: 'throttled',
+};
+
+describe('AccountSwitchModal', () => {
+ let onClose: ReturnType;
+ let onConfirmSwitch: ReturnType;
+ let onViewDashboard: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ onClose = vi.fn();
+ onConfirmSwitch = vi.fn();
+ onViewDashboard = vi.fn();
+ });
+
+ it('should export the component', async () => {
+ const mod = await import('../../../renderer/components/AccountSwitchModal');
+ expect(mod.AccountSwitchModal).toBeDefined();
+ expect(typeof mod.AccountSwitchModal).toBe('function');
+ });
+
+ it('should return null when isOpen is false', () => {
+ const { container } = renderWithLayerStack(
+
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('should display throttle reason with warning styling', () => {
+ renderWithLayerStack(
+
+ );
+
+ // Header should show the throttled title
+ expect(screen.getByText('Virtuoso Throttled')).toBeInTheDocument();
+ // Description should mention rate limiting
+ expect(screen.getByText('Virtuoso Account 1 has been rate limited')).toBeInTheDocument();
+ });
+
+ it('should display auth-expired reason with error styling', () => {
+ renderWithLayerStack(
+
+ );
+
+ expect(screen.getByText('Authentication Expired')).toBeInTheDocument();
+ expect(screen.getByText('Virtuoso Account 1 authentication has expired')).toBeInTheDocument();
+ });
+
+ it('should call onConfirmSwitch on "Switch Virtuoso" click', async () => {
+ renderWithLayerStack(
+
+ );
+
+ const switchButton = screen.getByText('Switch Virtuoso');
+ await act(async () => {
+ fireEvent.click(switchButton);
+ });
+
+ expect(onConfirmSwitch).toHaveBeenCalledTimes(1);
+ });
+
+ it('should dismiss on "Stay on Current" click', async () => {
+ renderWithLayerStack(
+
+ );
+
+ const stayButton = screen.getByText('Stay on Current');
+ await act(async () => {
+ fireEvent.click(stayButton);
+ });
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onViewDashboard on "View All Virtuosos" click', async () => {
+ renderWithLayerStack(
+
+ );
+
+ const viewButton = screen.getByText('View All Virtuosos');
+ await act(async () => {
+ fireEvent.click(viewButton);
+ });
+
+ expect(onViewDashboard).toHaveBeenCalledTimes(1);
+ });
+
+ it('should display both account names', () => {
+ renderWithLayerStack(
+
+ );
+
+ expect(screen.getByText('Account 1')).toBeInTheDocument();
+ expect(screen.getByText('Account 2')).toBeInTheDocument();
+ expect(screen.getByText('Current virtuoso')).toBeInTheDocument();
+ expect(screen.getByText('Recommended switch target')).toBeInTheDocument();
+ });
+
+ it('should display limit-approaching reason with usage percent', () => {
+ renderWithLayerStack(
+
+ );
+
+ expect(screen.getByText('Virtuoso Limit Reached')).toBeInTheDocument();
+ expect(screen.getByText('Virtuoso Account 1 is at 87% of its token limit')).toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 1cdb8e5d5..e63efa21c 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -2276,7 +2276,7 @@ function MaestroConsoleInner() {
type: 'rate_limited',
message: 'All virtuosos have been rate-limited. Waiting for automatic recovery...',
recoverable: true,
- agentId: (data.agentId as string) || 'claude-code',
+ agentId: 'claude-code',
timestamp: Date.now(),
},
batchState.currentDocumentIndex,
diff --git a/src/renderer/components/AccountSelector.tsx b/src/renderer/components/AccountSelector.tsx
index bea83f8df..de4b863fd 100644
--- a/src/renderer/components/AccountSelector.tsx
+++ b/src/renderer/components/AccountSelector.tsx
@@ -58,8 +58,8 @@ export function AccountSelector({
try {
const list = (await window.maestro.accounts.list()) as AccountProfile[];
if (!cancelled) setAccounts(list);
- } catch {
- // Silently fail - dropdown will show empty
+ } catch (err) {
+ console.warn('[AccountSelector] Failed to fetch accounts:', err);
}
})();
return () => { cancelled = true; };
@@ -205,9 +205,9 @@ export function AccountSelector({
style={{
width: `${Math.min(100, usage.usagePercent)}%`,
backgroundColor: usage.usagePercent >= 95
- ? '#ef4444'
+ ? theme.colors.error
: usage.usagePercent >= 80
- ? '#f59e0b'
+ ? theme.colors.warning
: theme.colors.accent,
}}
/>
diff --git a/src/renderer/components/AccountUsageHistory.tsx b/src/renderer/components/AccountUsageHistory.tsx
index d273ee1bb..b8d015bef 100644
--- a/src/renderer/components/AccountUsageHistory.tsx
+++ b/src/renderer/components/AccountUsageHistory.tsx
@@ -58,7 +58,7 @@ export function AccountUsageHistory({ accountId, theme }: { accountId: string; t
: Date.now() - (view === '7d' ? 7 : 30) * 24 * 60 * 60 * 1000;
const events = await window.maestro.accounts.getThrottleEvents(accountId, sinceMs);
setThrottleCount(events.length);
- } catch { /* non-fatal */ }
+ } catch (err) { console.warn('[AccountUsageHistory] Failed to load usage history:', err); }
setLoading(false);
}
load();
@@ -138,7 +138,7 @@ export function AccountUsageHistory({ accountId, theme }: { accountId: string; t
Avg: {formatTokenCount(Math.round(avgTokens))}/{view === 'monthly' ? 'mo' : 'day'}
Peak: {formatTokenCount(peakTokens)}
Throttles: 0 ? '#ef4444' : theme.colors.textMain
+ color: throttleCount > 0 ? theme.colors.error : theme.colors.textMain
}}>{throttleCount}
)}
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index 943ef89fe..ee73f7e6b 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -18,6 +18,7 @@ import type { AccountProfile, AccountSwitchConfig } from '../../shared/account-t
import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
import { AccountUsageHistory } from './AccountUsageHistory';
+import { useToast } from '../contexts/ToastContext';
const PLAN_PRESETS = [
{ label: 'Custom', tokens: 0, cost: null },
@@ -57,6 +58,7 @@ const WINDOW_DURATION_OPTIONS = [
];
export function AccountsPanel({ theme }: AccountsPanelProps) {
+ const { addToast } = useToast();
const [accounts, setAccounts] = useState([]);
const [switchConfig, setSwitchConfig] = useState(ACCOUNT_SWITCH_DEFAULTS);
const [discoveredAccounts, setDiscoveredAccounts] = useState(null);
@@ -244,11 +246,13 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
try {
const result = await window.maestro.accounts.validateSymlinks(configDir);
if (result.valid) {
- alert('All symlinks are valid.');
+ addToast({ type: 'success', title: 'Symlinks Valid', message: 'All symlinks are valid' });
} else {
- alert(
- `Symlink issues found:\nBroken: ${result.broken.join(', ') || 'none'}\nMissing: ${result.missing.join(', ') || 'none'}`
- );
+ addToast({
+ type: 'warning',
+ title: 'Symlink Issues Found',
+ message: `Broken: ${result.broken.join(', ') || 'none'} · Missing: ${result.missing.join(', ') || 'none'}`,
+ });
}
} catch (err) {
console.error('Failed to validate symlinks:', err);
@@ -259,9 +263,9 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
try {
const result = await window.maestro.accounts.repairSymlinks(configDir);
if (result.errors.length === 0) {
- alert(`Repaired: ${result.repaired.join(', ') || 'none needed'}`);
+ addToast({ type: 'success', title: 'Symlinks Repaired', message: `Repaired: ${result.repaired.join(', ') || 'none needed'}` });
} else {
- alert(`Repair errors: ${result.errors.join(', ')}`);
+ addToast({ type: 'error', title: 'Repair Failed', message: `Repair errors: ${result.errors.join(', ')}` });
}
await refreshAccounts();
} catch (err) {
@@ -274,7 +278,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
const result = await window.maestro.accounts.syncCredentials(configDir);
if (result.success) {
setErrorMessage(null);
- alert('Credentials synced from base ~/.claude directory.');
+ addToast({ type: 'success', title: 'Credentials Synced', message: 'Credentials synced from base ~/.claude directory' });
} else {
setErrorMessage(`Sync failed: ${result.error}`);
}
@@ -486,9 +490,9 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
style={{
width: `${Math.min(100, usage.usagePercent)}%`,
backgroundColor: usage.usagePercent >= 95
- ? '#ef4444'
+ ? theme.colors.error
: usage.usagePercent >= 80
- ? '#f59e0b'
+ ? theme.colors.warning
: theme.colors.accent,
}}
/>
@@ -533,9 +537,9 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
To limit:
~{formatTimeRemaining(usage.estimatedTimeToLimitMs)}
@@ -553,9 +557,9 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
Current rate:{' '}
{usage.prediction.linearTimeToLimitMs !== null
@@ -567,9 +571,9 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
Conservative (P90):{' '}
{usage.prediction.windowsRemainingP90 !== null
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 650fe5f1c..33e970fae 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2645,18 +2645,18 @@ interface MaestroAPI {
onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number; totalTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void;
onLimitWarning: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
onLimitReached: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
- onThrottled: (handler: (data: Record) => void) => () => void;
- onSwitchPrompt: (handler: (data: Record) => void) => () => void;
- onSwitchExecute: (handler: (data: Record) => void) => () => void;
- onStatusChanged: (handler: (data: Record) => void) => () => void;
+ onThrottled: (handler: (data: { accountId: string; accountName: string; sessionId: string; reason: string; message: string; tokensAtThrottle: number; autoSwitchAvailable: boolean; noAlternatives?: boolean }) => void) => () => void;
+ onSwitchPrompt: (handler: (data: { sessionId: string; fromAccountId: string; fromAccountName: string; toAccountId: string; toAccountName: string; reason: string; tokensAtThrottle: number }) => void) => () => void;
+ onSwitchExecute: (handler: (data: { sessionId: string; fromAccountId: string; fromAccountName: string; toAccountId: string; toAccountName: string; reason: string; automatic: boolean }) => void) => () => void;
+ onStatusChanged: (handler: (data: { accountId: string; accountName: string; oldStatus: string; newStatus: string; recoveredBy?: string }) => void) => () => void;
onAssigned: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
reconcileSessions: (activeSessionIds: string[]) => Promise<{ success: boolean; removed: number; corrections: Array<{ sessionId: string; accountId: string | null; accountName: string | null; configDir: string | null; status: 'valid' | 'removed' | 'inactive' }>; error?: string }>;
cleanupSession: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
executeSwitch: (params: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean }) => Promise<{ success: boolean; event?: unknown; error?: string }>;
- onSwitchStarted: (handler: (data: Record) => void) => () => void;
+ onSwitchStarted: (handler: (data: { sessionId: string; fromAccountId: string; toAccountId: string; toAccountName: string }) => void) => () => void;
onSwitchRespawn: (handler: (data: { sessionId: string; toAccountId: string; toAccountName: string; configDir: string; lastPrompt: string | null; reason: string }) => void) => () => void;
- onSwitchCompleted: (handler: (data: Record) => void) => () => void;
- onSwitchFailed: (handler: (data: Record) => void) => () => void;
+ onSwitchCompleted: (handler: (data: { sessionId: string; fromAccountId: string; toAccountId: string; reason: string; automatic: boolean; timestamp: number; fromAccountName: string; toAccountName: string }) => void) => () => void;
+ onSwitchFailed: (handler: (data: { sessionId: string; fromAccountId: string; toAccountId: string; error: string }) => void) => () => void;
triggerAuthRecovery: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
onAuthRecoveryStarted: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
onAuthRecoveryCompleted: (handler: (data: { sessionId: string; accountId: string; accountName: string }) => void) => () => void;
diff --git a/src/renderer/hooks/useAccountUsage.ts b/src/renderer/hooks/useAccountUsage.ts
index de56ab2f7..99d8c322f 100644
--- a/src/renderer/hooks/useAccountUsage.ts
+++ b/src/renderer/hooks/useAccountUsage.ts
@@ -254,7 +254,8 @@ export function useAccountUsage(): {
}
setMetrics(newMetrics);
setLoading(false);
- } catch {
+ } catch (err) {
+ console.warn('[useAccountUsage] Failed to fetch usage data:', err);
setLoading(false);
}
}, [calculateDerivedMetrics]);
@@ -277,10 +278,10 @@ export function useAccountUsage(): {
windowStart: w.windowStart,
windowEnd: w.windowEnd,
}));
- } catch { /* skip individual account errors */ }
+ } catch (err) { console.warn(`[useAccountUsage] Failed to load history for account ${account.id}:`, err); }
}
windowHistoriesRef.current = histories;
- } catch { /* non-fatal */ }
+ } catch (err) { console.warn('[useAccountUsage] Failed to load window histories:', err); }
}
loadHistories();
}, []);
From f0d156543a06274efe6cda8b5e37e58c64722861 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 15 Feb 2026 21:29:53 -0500
Subject: [PATCH 26/59] MAESTRO: feat: add tabbed Configuration/Usage views to
VirtuososModal
Convert VirtuososModal from single-panel to two-tab layout:
- Configuration tab: existing AccountsPanel (unchanged)
- Usage tab: new VirtuosoUsageView with aggregate summary, per-account
usage cards, predictions section, historical expandables, and throttle
event timeline
- Keyboard navigation (Cmd/Ctrl+Shift+[/]) following UsageDashboardModal
pattern
- Dynamic modal width (720px config, 900px usage)
- Sessions prop passed from App.tsx for session assignment display
Co-Authored-By: Claude Opus 4.6
---
.../components/VirtuososModal.test.tsx | 184 ++++++
src/renderer/App.tsx | 10 +
src/renderer/components/VirtuosoUsageView.tsx | 554 ++++++++++++++++++
src/renderer/components/VirtuososModal.tsx | 104 +++-
4 files changed, 844 insertions(+), 8 deletions(-)
create mode 100644 src/__tests__/renderer/components/VirtuososModal.test.tsx
create mode 100644 src/renderer/components/VirtuosoUsageView.tsx
diff --git a/src/__tests__/renderer/components/VirtuososModal.test.tsx b/src/__tests__/renderer/components/VirtuososModal.test.tsx
new file mode 100644
index 000000000..4bf87fbc8
--- /dev/null
+++ b/src/__tests__/renderer/components/VirtuososModal.test.tsx
@@ -0,0 +1,184 @@
+/**
+ * @fileoverview Tests for VirtuososModal component
+ * Tests: tab rendering, tab switching, keyboard navigation, tab state persistence
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import type { Theme } from '../../../renderer/types';
+
+// Mock Modal component to avoid layer stack / hook dependencies
+vi.mock('../../../renderer/components/ui/Modal', () => ({
+ Modal: ({
+ children,
+ title,
+ }: {
+ children: React.ReactNode;
+ title: string;
+ [key: string]: unknown;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock child components to isolate VirtuososModal tab logic
+vi.mock('../../../renderer/components/AccountsPanel', () => ({
+ AccountsPanel: () => AccountsPanel
,
+}));
+
+vi.mock('../../../renderer/components/VirtuosoUsageView', () => ({
+ VirtuosoUsageView: () => (
+ VirtuosoUsageView
+ ),
+}));
+
+// Import after mocks
+import { VirtuososModal } from '../../../renderer/components/VirtuososModal';
+
+const createTheme = (): Theme => ({
+ id: 'dracula',
+ name: 'Dracula',
+ mode: 'dark',
+ colors: {
+ bgMain: '#1a1a2e',
+ bgSidebar: '#16213e',
+ bgActivity: '#0f3460',
+ textMain: '#e8e8e8',
+ textDim: '#888888',
+ accent: '#7b2cbf',
+ accentDim: '#7b2cbf40',
+ accentText: '#7b2cbf',
+ accentForeground: '#ffffff',
+ border: '#333355',
+ success: '#22c55e',
+ warning: '#f59e0b',
+ error: '#ef4444',
+ },
+});
+
+describe('VirtuososModal', () => {
+ const theme = createTheme();
+ const onClose = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders nothing when isOpen is false', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders Configuration tab by default', () => {
+ render( );
+
+ const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
+
+ expect(configTab).toBeDefined();
+ expect(usageTab).toBeDefined();
+
+ expect(configTab.getAttribute('aria-selected')).toBe('true');
+ expect(usageTab.getAttribute('aria-selected')).toBe('false');
+
+ expect(screen.getByTestId('accounts-panel')).toBeDefined();
+ });
+
+ it('switches to Usage tab on click', () => {
+ render( );
+
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
+ fireEvent.click(usageTab);
+
+ expect(usageTab.getAttribute('aria-selected')).toBe('true');
+
+ const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ expect(configTab.getAttribute('aria-selected')).toBe('false');
+
+ expect(screen.getByTestId('virtuoso-usage-view')).toBeDefined();
+ });
+
+ it('cycles tabs with Cmd+Shift+]', async () => {
+ render( );
+
+ const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
+
+ expect(configTab.getAttribute('aria-selected')).toBe('true');
+
+ fireEvent.keyDown(window, {
+ key: ']',
+ metaKey: true,
+ shiftKey: true,
+ });
+
+ await waitFor(() => {
+ expect(usageTab.getAttribute('aria-selected')).toBe('true');
+ });
+ });
+
+ it('cycles tabs with Cmd+Shift+[', async () => {
+ render( );
+
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
+ fireEvent.click(usageTab);
+ expect(usageTab.getAttribute('aria-selected')).toBe('true');
+
+ fireEvent.keyDown(window, {
+ key: '[',
+ metaKey: true,
+ shiftKey: true,
+ });
+
+ const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ await waitFor(() => {
+ expect(configTab.getAttribute('aria-selected')).toBe('true');
+ });
+ });
+
+ it('preserves tab state when modal stays open', () => {
+ render( );
+
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
+ fireEvent.click(usageTab);
+ expect(usageTab.getAttribute('aria-selected')).toBe('true');
+
+ // State persists without external reset
+ expect(usageTab.getAttribute('aria-selected')).toBe('true');
+ });
+
+ it('renders tablist with correct aria attributes', () => {
+ render( );
+
+ const tablist = screen.getByRole('tablist');
+ expect(tablist).toBeDefined();
+ expect(tablist.getAttribute('aria-label')).toBe('Virtuosos view');
+ });
+
+ it('wraps around when cycling past last tab', async () => {
+ render( );
+
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
+ fireEvent.click(usageTab);
+
+ // Press ] to wrap from last to first
+ fireEvent.keyDown(window, {
+ key: ']',
+ metaKey: true,
+ shiftKey: true,
+ });
+
+ const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ await waitFor(() => {
+ expect(configTab.getAttribute('aria-selected')).toBe('true');
+ });
+ });
+});
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index e63efa21c..b4c0467bd 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1441,6 +1441,13 @@ function MaestroConsoleInner() {
return session;
}));
}
+ // Re-register assignments for sessions that have accountId but were
+ // created before the assign() call was added to session creation
+ for (const session of restoredSessions) {
+ if (session.accountId && session.toolType === 'claude-code') {
+ window.maestro.accounts.assign(session.id, session.accountId).catch(() => {});
+ }
+ }
} catch (reconcileError) {
console.error('[App] Account reconciliation failed:', reconcileError);
}
@@ -8290,6 +8297,8 @@ You are taking over this conversation. Based on the context above, provide a bri
if (defaultAccount) {
newSession.accountId = defaultAccount.id;
newSession.accountName = defaultAccount.name;
+ // Register assignment with main process so usage listener tracks this session
+ window.maestro.accounts.assign(newId, defaultAccount.id).catch(() => {});
}
} catch {
// Accounts not configured or unavailable — proceed without assignment
@@ -12773,6 +12782,7 @@ You are taking over this conversation. Based on the context above, provide a bri
isOpen={virtuososOpen}
onClose={() => setVirtuososOpen(false)}
theme={theme}
+ sessions={sessions}
/>
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx
new file mode 100644
index 000000000..827e30d23
--- /dev/null
+++ b/src/renderer/components/VirtuosoUsageView.tsx
@@ -0,0 +1,554 @@
+/**
+ * VirtuosoUsageView - Usage tab content for the VirtuososModal
+ *
+ * Presents account usage data in three sections:
+ * A) Current Window Overview — aggregate summary + per-account usage cards
+ * B) Predictions — linear/P90 time-to-limit estimates
+ * C) Historical — per-account expandable history + throttle event timeline
+ */
+
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import {
+ Activity,
+ AlertTriangle,
+ ChevronDown,
+ ChevronRight,
+ Clock,
+ TrendingUp,
+ Users,
+ Zap,
+} from 'lucide-react';
+import type { Theme, Session } from '../types';
+import type { AccountProfile } from '../../shared/account-types';
+import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
+import { AccountUsageHistory } from './AccountUsageHistory';
+
+interface ThrottleEvent {
+ timestamp: number;
+ accountId: string;
+ accountName?: string;
+ reason: string;
+ totalTokens: number;
+ recoveryAction?: string;
+}
+
+interface VirtuosoUsageViewProps {
+ theme: Theme;
+ sessions?: Session[];
+}
+
+export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
+ const { metrics, loading } = useAccountUsage();
+ const [accounts, setAccounts] = useState([]);
+ const [throttleEvents, setThrottleEvents] = useState([]);
+ const [expandedAccountId, setExpandedAccountId] = useState(null);
+
+ const fetchData = useCallback(async () => {
+ try {
+ const [accountList, events] = await Promise.all([
+ window.maestro.accounts.list() as Promise,
+ window.maestro.accounts.getThrottleEvents() as Promise,
+ ]);
+ setAccounts(accountList || []);
+ setThrottleEvents(events || []);
+ } catch (err) {
+ console.warn('[VirtuosoUsageView] Failed to fetch data:', err);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchData();
+ }, [fetchData]);
+
+ // Derive aggregate stats from metrics
+ const metricsArray = useMemo(() => Object.values(metrics), [metrics]);
+
+ const activeCount = useMemo(() => {
+ return accounts.filter((a) => a.status === 'active').length;
+ }, [accounts]);
+
+ const totalTokensThisWindow = useMemo(() => {
+ return metricsArray.reduce((sum, m) => sum + m.totalTokens, 0);
+ }, [metricsArray]);
+
+ const totalCostThisWindow = useMemo(() => {
+ return metricsArray.reduce((sum, m) => sum + m.costUsd, 0);
+ }, [metricsArray]);
+
+ // Count sessions per account
+ const sessionCountByAccount = useMemo(() => {
+ if (!sessions) return {};
+ const counts: Record = {};
+ for (const s of sessions) {
+ if (s.accountId) {
+ counts[s.accountId] = (counts[s.accountId] || 0) + 1;
+ }
+ }
+ return counts;
+ }, [sessions]);
+
+ // Accounts with token limits configured (for predictions section)
+ const accountsWithLimits = useMemo(() => {
+ return metricsArray.filter((m) => m.limitTokens > 0);
+ }, [metricsArray]);
+
+ if (loading && accounts.length === 0) {
+ return (
+
+
+
+ Loading usage data...
+
+
+ );
+ }
+
+ if (accounts.length === 0) {
+ return (
+
+
+
+ No Virtuoso accounts configured.
+
+
+ Switch to the Configuration tab to add accounts.
+
+
+ );
+ }
+
+ return (
+
+ {/* Section A: Aggregate Summary */}
+
+
+
+ {accounts.length}
+
+
+ Virtuosos
+
+
+
+
+ {activeCount}
+
+
+ Active
+
+
+
+
+ {formatTokenCount(totalTokensThisWindow)}
+
+
+ Tokens This Window
+
+
+
+
+ ${totalCostThisWindow.toFixed(2)}
+
+
+ Cost This Window
+
+
+
+
+ {/* Section A: Per-Account Usage Cards */}
+
+
+
+ Current Window
+
+
+ {accounts.map((account) => {
+ const usage = metrics[account.id];
+ const severityColor = getSeverityColor(usage?.usagePercent, theme);
+ const sessionCount = sessionCountByAccount[account.id] || 0;
+
+ return (
+
+ {/* Header */}
+
+
+
+ {account.name || account.email}
+
+
+ {account.status}
+
+
+ {sessions && sessionCount > 0 && (
+
+ {sessionCount} session{sessionCount !== 1 ? 's' : ''}
+
+ )}
+
+
+ {usage ? (
+ <>
+ {/* Usage bar */}
+ {usage.limitTokens > 0 && (
+
+
+
+
+ {formatTokenCount(usage.totalTokens)} /{' '}
+ {formatTokenCount(usage.limitTokens)}
+
+
+ {usage.usagePercent?.toFixed(0) ?? 0}%
+
+
+
+ )}
+
+ {/* Metrics grid */}
+
+ {usage.limitTokens === 0 && (
+
+ Tokens:{' '}
+
+ {formatTokenCount(usage.totalTokens)}
+
+
+ )}
+
+ Cost:{' '}
+
+ ${usage.costUsd.toFixed(2)}
+
+
+
+ Queries:{' '}
+
+ {usage.queryCount}
+
+
+
+ Burn:{' '}
+
+ ~{formatTokenCount(Math.round(usage.burnRatePerHour))}/hr
+
+
+ {usage.estimatedTimeToLimitMs !== null && (
+
+ TTL:{' '}
+
+ {formatTimeRemaining(usage.estimatedTimeToLimitMs)}
+
+
+ )}
+
+ Reset:{' '}
+
+ {formatTimeRemaining(usage.timeRemainingMs)}
+
+
+
+ >
+ ) : (
+
+ No usage data for current window
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* Section B: Predictions */}
+ {accountsWithLimits.length > 0 && (
+
+
+
+ Predictions
+
+
+ {accountsWithLimits.map((usage) => {
+ const account = accounts.find((a) => a.id === usage.accountId);
+ if (!account) return null;
+ const pred = usage.prediction;
+
+ return (
+
+
+
+ {account.name || account.email}
+
+
+ {pred.confidence} confidence
+
+
+
+
+ Linear TTL:{' '}
+
+ {pred.linearTimeToLimitMs
+ ? formatTimeRemaining(pred.linearTimeToLimitMs)
+ : '—'}
+
+
+
+ P90 est:{' '}
+
+ {pred.weightedTimeToLimitMs
+ ? formatTimeRemaining(pred.weightedTimeToLimitMs)
+ : '—'}
+
+
+
+ Avg/window:{' '}
+
+ {formatTokenCount(Math.round(pred.avgTokensPerWindow))}
+
+
+
+ Windows remaining (P90):{' '}
+
+ {pred.windowsRemainingP90 !== null
+ ? pred.windowsRemainingP90.toFixed(1)
+ : '—'}
+
+
+
+
+ );
+ })}
+
+ {/* Aggregate exhaustion warning */}
+ {(() => {
+ const exhaustingSoon = accountsWithLimits.filter(
+ (m) =>
+ m.prediction.linearTimeToLimitMs !== null &&
+ m.prediction.linearTimeToLimitMs < 24 * 60 * 60 * 1000
+ );
+ if (exhaustingSoon.length === 0) return null;
+ const soonestMs = Math.min(
+ ...exhaustingSoon.map((m) => m.prediction.linearTimeToLimitMs!)
+ );
+ return (
+
+
+
+ At current rates, {exhaustingSoon.length} account
+ {exhaustingSoon.length !== 1 ? 's' : ''} will reach limit
+ within {formatTimeRemaining(soonestMs)}
+
+
+ );
+ })()}
+
+
+ )}
+
+ {/* Section C: Historical Usage */}
+
+
+
+ Historical Usage
+
+
+ {accounts.map((account) => (
+
+
+ setExpandedAccountId(
+ expandedAccountId === account.id ? null : account.id
+ )
+ }
+ className="w-full flex items-center gap-2 py-2 px-2 rounded-lg text-xs text-left transition-colors"
+ style={{ color: theme.colors.textMain }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+ {expandedAccountId === account.id ? (
+
+ ) : (
+
+ )}
+
+ {account.name || account.email}
+
+
+ {expandedAccountId === account.id && (
+
+ )}
+
+ ))}
+
+
+
+ {/* Section C: Throttle Event Timeline */}
+
+
+
+ Recent Throttle Events
+
+ {throttleEvents.length === 0 ? (
+
+ No throttle events recorded
+
+ ) : (
+
+ {throttleEvents.slice(0, 20).map((event, i) => (
+
+
+ {new Date(event.timestamp).toLocaleString()}
+
+
+ {event.accountName || event.accountId}
+
+
+ {formatTokenCount(event.totalTokens)} tokens
+
+ {event.recoveryAction && (
+
+ → {event.recoveryAction}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// Helpers
+
+function getSeverityColor(usagePercent: number | null | undefined, theme: Theme): string {
+ if (usagePercent == null) return theme.colors.accent;
+ if (usagePercent > 80) return theme.colors.error;
+ if (usagePercent > 60) return theme.colors.warning;
+ return theme.colors.success;
+}
+
+function getStatusColor(
+ status: string,
+ theme: Theme
+): { bg: string; fg: string } {
+ const styles: Record = {
+ active: { bg: theme.colors.success + '20', fg: theme.colors.success },
+ throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning },
+ disabled: { bg: theme.colors.error + '20', fg: theme.colors.error },
+ };
+ return styles[status] || styles.disabled;
+}
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
index 1651e1412..b01b14d55 100644
--- a/src/renderer/components/VirtuososModal.tsx
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -1,26 +1,64 @@
/**
* VirtuososModal - Standalone modal for account (Virtuoso) management
*
- * Wraps AccountsPanel in its own top-level modal, accessible from the
- * hamburger menu. Previously, accounts were nested under Settings.
+ * Two-tab layout:
+ * 1. Configuration — AccountsPanel (account CRUD, discovery, plan presets, auto-switch)
+ * 2. Usage — VirtuosoUsageView (real-time metrics, predictions, history, throttle events)
*/
-import React from 'react';
-import { Users } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { Users, Settings, BarChart3 } from 'lucide-react';
import { AccountsPanel } from './AccountsPanel';
+import { VirtuosoUsageView } from './VirtuosoUsageView';
import { Modal } from './ui/Modal';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
-import type { Theme } from '../types';
+import type { Theme, Session } from '../types';
+
+type VirtuosoTab = 'config' | 'usage';
+
+const VIRTUOSO_TABS: { value: VirtuosoTab; label: string; icon: typeof Settings }[] = [
+ { value: 'config', label: 'Configuration', icon: Settings },
+ { value: 'usage', label: 'Usage', icon: BarChart3 },
+];
interface VirtuososModalProps {
isOpen: boolean;
onClose: () => void;
theme: Theme;
+ sessions?: Session[];
}
-export function VirtuososModal({ isOpen, onClose, theme }: VirtuososModalProps) {
+export function VirtuososModal({ isOpen, onClose, theme, sessions }: VirtuososModalProps) {
+ const [activeTab, setActiveTab] = useState('config');
+
+ // Keyboard navigation: Cmd/Ctrl+Shift+[ and Cmd/Ctrl+Shift+]
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === '[' || e.key === ']')) {
+ e.preventDefault();
+ e.stopPropagation();
+ const currentIndex = VIRTUOSO_TABS.findIndex((t) => t.value === activeTab);
+ if (e.key === '[') {
+ const prev = currentIndex > 0 ? currentIndex - 1 : VIRTUOSO_TABS.length - 1;
+ setActiveTab(VIRTUOSO_TABS[prev].value);
+ } else {
+ const next =
+ currentIndex < VIRTUOSO_TABS.length - 1 ? currentIndex + 1 : 0;
+ setActiveTab(VIRTUOSO_TABS[next].value);
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown, true);
+ return () => window.removeEventListener('keydown', handleKeyDown, true);
+ }, [isOpen, activeTab]);
+
if (!isOpen) return null;
+ const modalWidth = activeTab === 'usage' ? 900 : 720;
+
return (
}
- width={720}
+ width={modalWidth}
closeOnBackdropClick
>
@@ -36,7 +74,57 @@ export function VirtuososModal({ isOpen, onClose, theme }: VirtuososModalProps)
AI Provider Accounts
-
+
+ {/* Tab bar */}
+
+ {VIRTUOSO_TABS.map((tab) => {
+ const Icon = tab.icon;
+ return (
+ setActiveTab(tab.value)}
+ className="px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
+ style={{
+ backgroundColor:
+ activeTab === tab.value
+ ? `${theme.colors.accent}20`
+ : 'transparent',
+ color:
+ activeTab === tab.value
+ ? theme.colors.accent
+ : theme.colors.textDim,
+ }}
+ onMouseEnter={(e) => {
+ if (activeTab !== tab.value) {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (activeTab !== tab.value) {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }
+ }}
+ role="tab"
+ aria-selected={activeTab === tab.value}
+ aria-controls={`tabpanel-${tab.value}`}
+ id={`virtuoso-tab-${tab.value}`}
+ tabIndex={-1}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+
+ {/* Tab content */}
+ {activeTab === 'config' && }
+ {activeTab === 'usage' && }
);
}
From d254957db07786cb7ff7ff8d7a56034aa2fad6a1 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 00:54:55 -0500
Subject: [PATCH 27/59] MAESTRO: feat: surface per-token-type breakdown in
Virtuosos usage UI
The usage view was collapsing all token types into a single totalTokens
value. The backend already stored inputTokens, outputTokens,
cacheReadTokens, and cacheCreationTokens separately but the renderer
never extracted or displayed them. Additionally, real-time usage-update
events were only broadcast for accounts with token limits configured,
leaving unlimited accounts with stale data.
Key fixes:
- Broadcast account:usage-update for ALL accounts, not just those with limits
- Add token breakdown fields to AccountUsageUpdate interface and IPC event
- Propagate inputTokens/outputTokens/cacheReadTokens/cacheCreationTokens
through useAccountUsage hook and AccountUsageMetrics interface
- Add per-account token breakdown grid (In/Out/Cache R/Cache W) in usage cards
- Add stacked color-coded bars with legend in AccountUsageHistory
- Fix daily/monthly SQL to query account_usage_windows instead of
query_events (which lacked account_id population)
Co-Authored-By: Claude Opus 4.6
---
src/main/preload/accounts.ts | 6 ++-
.../account-usage-listener.ts | 48 +++++++++++--------
src/main/stats/account-usage.ts | 18 +++----
.../components/AccountUsageHistory.tsx | 46 ++++++++++++++----
src/renderer/components/VirtuosoUsageView.tsx | 33 ++++++++++++-
src/renderer/global.d.ts | 2 +-
src/renderer/hooks/useAccountUsage.ts | 16 +++++++
7 files changed, 127 insertions(+), 42 deletions(-)
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index 3788b8c57..655e5f41e 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -19,8 +19,12 @@ import { ipcRenderer } from 'electron';
*/
export interface AccountUsageUpdate {
accountId: string;
- usagePercent: number;
+ usagePercent: number | null;
totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
limitTokens: number;
windowStart: number;
windowEnd: number;
diff --git a/src/main/process-listeners/account-usage-listener.ts b/src/main/process-listeners/account-usage-listener.ts
index d2ee3a3aa..f959e55f6 100644
--- a/src/main/process-listeners/account-usage-listener.ts
+++ b/src/main/process-listeners/account-usage-listener.ts
@@ -63,26 +63,33 @@ export function setupAccountUsageListener(
costUsd: usageStats.totalCostUsd || 0,
});
- // Calculate usage percentage if limit is configured
- if (account.tokenLimitPerWindow > 0) {
- const windowUsage = statsDb.getAccountUsageInWindow(account.id, start, end);
- const totalTokens = windowUsage.inputTokens + windowUsage.outputTokens
- + windowUsage.cacheReadTokens + windowUsage.cacheCreationTokens;
- const usagePercent = Math.min(100, (totalTokens / account.tokenLimitPerWindow) * 100);
-
- // Broadcast usage update to renderer for real-time dashboard
- safeSend('account:usage-update', {
- accountId: account.id,
- usagePercent,
- totalTokens,
- limitTokens: account.tokenLimitPerWindow,
- windowStart: start,
- windowEnd: end,
- queryCount: windowUsage.queryCount,
- costUsd: windowUsage.costUsd,
- });
-
- // Check warning threshold
+ // Read back aggregated window usage and broadcast to renderer
+ const windowUsage = statsDb.getAccountUsageInWindow(account.id, start, end);
+ const totalTokens = windowUsage.inputTokens + windowUsage.outputTokens
+ + windowUsage.cacheReadTokens + windowUsage.cacheCreationTokens;
+ const limitTokens = account.tokenLimitPerWindow || 0;
+ const usagePercent = limitTokens > 0
+ ? Math.min(100, (totalTokens / limitTokens) * 100)
+ : null;
+
+ // Broadcast usage update to renderer for real-time dashboard
+ safeSend('account:usage-update', {
+ accountId: account.id,
+ usagePercent,
+ totalTokens,
+ inputTokens: windowUsage.inputTokens,
+ outputTokens: windowUsage.outputTokens,
+ cacheReadTokens: windowUsage.cacheReadTokens,
+ cacheCreationTokens: windowUsage.cacheCreationTokens,
+ limitTokens,
+ windowStart: start,
+ windowEnd: end,
+ queryCount: windowUsage.queryCount,
+ costUsd: windowUsage.costUsd,
+ });
+
+ // Check warning/auto-switch thresholds (only if limit is configured)
+ if (limitTokens > 0 && usagePercent !== null) {
const switchConfig = accountRegistry.getSwitchConfig();
if (usagePercent >= switchConfig.warningThresholdPercent && usagePercent < switchConfig.autoSwitchThresholdPercent) {
safeSend('account:limit-warning', {
@@ -93,7 +100,6 @@ export function setupAccountUsageListener(
});
}
- // Check auto-switch threshold
if (usagePercent >= switchConfig.autoSwitchThresholdPercent) {
safeSend('account:limit-reached', {
accountId: account.id,
diff --git a/src/main/stats/account-usage.ts b/src/main/stats/account-usage.ts
index 789cb9729..030f49b50 100644
--- a/src/main/stats/account-usage.ts
+++ b/src/main/stats/account-usage.ts
@@ -237,33 +237,33 @@ export interface AccountMonthlyUsage {
const DAILY_USAGE_SQL = `
SELECT
- date(start_time / 1000, 'unixepoch', 'localtime') as date,
+ date(window_start / 1000, 'unixepoch', 'localtime') as date,
COALESCE(SUM(input_tokens), 0) as inputTokens,
COALESCE(SUM(output_tokens), 0) as outputTokens,
COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens,
COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens,
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens), 0) as totalTokens,
COALESCE(SUM(cost_usd), 0) as costUsd,
- COUNT(*) as queryCount
- FROM query_events
- WHERE account_id = ? AND start_time >= ? AND start_time < ?
+ COALESCE(SUM(query_count), 0) as queryCount
+ FROM account_usage_windows
+ WHERE account_id = ? AND window_start >= ? AND window_start < ?
GROUP BY date
ORDER BY date ASC
`;
const MONTHLY_USAGE_SQL = `
SELECT
- strftime('%Y-%m', start_time / 1000, 'unixepoch', 'localtime') as month,
+ strftime('%Y-%m', window_start / 1000, 'unixepoch', 'localtime') as month,
COALESCE(SUM(input_tokens), 0) as inputTokens,
COALESCE(SUM(output_tokens), 0) as outputTokens,
COALESCE(SUM(cache_read_tokens), 0) as cacheReadTokens,
COALESCE(SUM(cache_creation_tokens), 0) as cacheCreationTokens,
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_creation_tokens), 0) as totalTokens,
COALESCE(SUM(cost_usd), 0) as costUsd,
- COUNT(*) as queryCount,
- COUNT(DISTINCT date(start_time / 1000, 'unixepoch', 'localtime')) as daysActive
- FROM query_events
- WHERE account_id = ? AND start_time >= ? AND start_time < ?
+ COALESCE(SUM(query_count), 0) as queryCount,
+ COUNT(DISTINCT date(window_start / 1000, 'unixepoch', 'localtime')) as daysActive
+ FROM account_usage_windows
+ WHERE account_id = ? AND window_start >= ? AND window_start < ?
GROUP BY month
ORDER BY month ASC
`;
diff --git a/src/renderer/components/AccountUsageHistory.tsx b/src/renderer/components/AccountUsageHistory.tsx
index b8d015bef..4f594e820 100644
--- a/src/renderer/components/AccountUsageHistory.tsx
+++ b/src/renderer/components/AccountUsageHistory.tsx
@@ -4,6 +4,10 @@ import { formatTokenCount } from '../hooks/useAccountUsage';
interface AccountDailyUsage {
date: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
totalTokens: number;
costUsd: number;
queryCount: number;
@@ -11,6 +15,10 @@ interface AccountDailyUsage {
interface AccountMonthlyUsage {
month: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
totalTokens: number;
costUsd: number;
queryCount: number;
@@ -64,7 +72,7 @@ export function AccountUsageHistory({ accountId, theme }: { accountId: string; t
load();
}, [accountId, view]);
- const data: Array<{ totalTokens: number; costUsd: number; queryCount: number }> & Array =
+ const data: Array<{ inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; totalTokens: number; costUsd: number; queryCount: number }> & Array =
view === 'monthly' ? monthlyData : dailyData;
const totalTokens = data.reduce((sum, d) => sum + d.totalTokens, 0);
const avgTokens = data.length > 0 ? totalTokens / data.length : 0;
@@ -90,6 +98,22 @@ export function AccountUsageHistory({ accountId, theme }: { accountId: string; t
))}
+ {/* Bar legend */}
+
+
+ In
+
+
+ Out
+
+
+ Cache R
+
+
+ Cache W
+
+
+
{/* Data rows */}
{loading ? (
Loading...
@@ -100,22 +124,26 @@ export function AccountUsageHistory({ accountId, theme }: { accountId: string; t
{data.map((row, i) => {
const label = 'date' in row ? (row as AccountDailyUsage).date : (row as AccountMonthlyUsage).month;
const barWidth = maxTokens > 0 ? (row.totalTokens / maxTokens) * 100 : 0;
+ const total = row.totalTokens || 1;
+ const inPct = (row.inputTokens / total) * barWidth;
+ const outPct = (row.outputTokens / total) * barWidth;
+ const cacheRPct = (row.cacheReadTokens / total) * barWidth;
+ const cacheWPct = (row.cacheCreationTokens / total) * barWidth;
+ const tooltip = `In: ${formatTokenCount(row.inputTokens)} | Out: ${formatTokenCount(row.outputTokens)} | Cache R: ${formatTokenCount(row.cacheReadTokens)} | Cache W: ${formatTokenCount(row.cacheCreationTokens)}`;
return (
{formatDateLabel(label, view)}
{formatTokenCount(row.totalTokens)}
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx
index 827e30d23..2ddc413bb 100644
--- a/src/renderer/components/VirtuosoUsageView.tsx
+++ b/src/renderer/components/VirtuosoUsageView.tsx
@@ -249,6 +249,37 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
)}
+ {/* Token breakdown */}
+
+
+ In:{' '}
+
+ {formatTokenCount(usage.inputTokens)}
+
+
+
+ Out:{' '}
+
+ {formatTokenCount(usage.outputTokens)}
+
+
+
+ Cache R:{' '}
+
+ {formatTokenCount(usage.cacheReadTokens)}
+
+
+
+ Cache W:{' '}
+
+ {formatTokenCount(usage.cacheCreationTokens)}
+
+
+
+
{/* Metrics grid */}
{usage.limitTokens === 0 && (
- Tokens:{' '}
+ Total:{' '}
{formatTokenCount(usage.totalTokens)}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 33e970fae..0f9309235 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2642,7 +2642,7 @@ interface MaestroAPI {
removeDirectory: (configDir: string) => Promise<{ success: boolean; error?: string }>;
validateRemoteDir: (params: { sshConfig: { host: string; user?: string; port?: number }; configDir: string }) => Promise<{ exists: boolean; hasAuth: boolean; symlinksValid: boolean; error?: string }>;
syncCredentials: (configDir: string) => Promise<{ success: boolean; error?: string }>;
- onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number; totalTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void;
+ onUsageUpdate: (handler: (data: { accountId: string; usagePercent: number | null; totalTokens: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; limitTokens: number; windowStart: number; windowEnd: number; queryCount: number; costUsd: number }) => void) => () => void;
onLimitWarning: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
onLimitReached: (handler: (data: { accountId: string; accountName: string; usagePercent: number; sessionId: string }) => void) => () => void;
onThrottled: (handler: (data: { accountId: string; accountName: string; sessionId: string; reason: string; message: string; tokensAtThrottle: number; autoSwitchAvailable: boolean; noAlternatives?: boolean }) => void) => () => void;
diff --git a/src/renderer/hooks/useAccountUsage.ts b/src/renderer/hooks/useAccountUsage.ts
index 99d8c322f..31a7ef77e 100644
--- a/src/renderer/hooks/useAccountUsage.ts
+++ b/src/renderer/hooks/useAccountUsage.ts
@@ -26,6 +26,10 @@ export interface UsagePrediction {
export interface AccountUsageMetrics {
accountId: string;
totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
limitTokens: number;
usagePercent: number | null;
costUsd: number;
@@ -170,6 +174,10 @@ export function useAccountUsage(): {
const calculateDerivedMetrics = useCallback((raw: {
accountId: string;
totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
limitTokens: number;
usagePercent: number | null;
costUsd: number;
@@ -243,6 +251,10 @@ export function useAccountUsage(): {
newMetrics[accountId] = calculateDerivedMetrics({
accountId,
totalTokens: usage.totalTokens || 0,
+ inputTokens: usage.inputTokens || 0,
+ outputTokens: usage.outputTokens || 0,
+ cacheReadTokens: usage.cacheReadTokens || 0,
+ cacheCreationTokens: usage.cacheCreationTokens || 0,
limitTokens: usage.account?.tokenLimitPerWindow || 0,
usagePercent: usage.usagePercent ?? null,
costUsd: usage.costUsd || 0,
@@ -299,6 +311,10 @@ export function useAccountUsage(): {
[accountId]: calculateDerivedMetrics({
accountId,
totalTokens: data.totalTokens || 0,
+ inputTokens: data.inputTokens || 0,
+ outputTokens: data.outputTokens || 0,
+ cacheReadTokens: data.cacheReadTokens || 0,
+ cacheCreationTokens: data.cacheCreationTokens || 0,
limitTokens: data.limitTokens || 0,
usagePercent: data.usagePercent ?? null,
costUsd: data.costUsd || 0,
From f7b31151036f1ae6fe291f54d2aaa075f1a0bc6a Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 02:28:01 -0500
Subject: [PATCH 28/59] MAESTRO: feat: add Trends & Analytics section with
sparklines and rate metrics to Virtuosos usage view
- Add RateMetrics interface and calculateRateMetrics() to useAccountUsage hook
with tokens/hr, tokens/day, tokens/week, period-over-period deltas, and
linear regression trend detection
- Create AccountTrendChart SVG component with full chart and compact sparkline modes
- Create AccountRateMetrics panel component with rate display and trend indicator
- Integrate 7-day sparklines into per-account usage cards
- Add burn rate trend arrows (up/down unicode indicators)
- Add new Trends & Analytics section between Predictions and Historical Usage
- Increase window history fetch from 40 to 68 for 2-week delta calculations
Co-Authored-By: Claude Opus 4.6
---
.../UsageDashboard/AccountRateMetrics.tsx | 88 +++++++
.../UsageDashboard/AccountTrendChart.tsx | 232 ++++++++++++++++++
src/renderer/components/VirtuosoUsageView.tsx | 64 +++++
src/renderer/hooks/useAccountUsage.ts | 101 +++++++-
4 files changed, 484 insertions(+), 1 deletion(-)
create mode 100644 src/renderer/components/UsageDashboard/AccountRateMetrics.tsx
create mode 100644 src/renderer/components/UsageDashboard/AccountTrendChart.tsx
diff --git a/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx b/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx
new file mode 100644
index 000000000..9a968aa2b
--- /dev/null
+++ b/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx
@@ -0,0 +1,88 @@
+/**
+ * AccountRateMetrics - Compact panel showing token consumption rates
+ * at three time scales with period-over-period deltas and trend indicator.
+ */
+
+import React from 'react';
+import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import type { Theme } from '../../types';
+import type { RateMetrics } from '../../hooks/useAccountUsage';
+import { formatTokenCount } from '../../hooks/useAccountUsage';
+
+interface AccountRateMetricsProps {
+ rateMetrics: RateMetrics;
+ theme: Theme;
+}
+
+function formatDelta(delta: number, theme: Theme): { text: string; color: string } {
+ if (delta === 0 || isNaN(delta)) {
+ return { text: '\u2014', color: theme.colors.textDim };
+ }
+ const capped = Math.max(-999, Math.min(999, delta));
+ if (capped > 0) {
+ return { text: `+${capped.toFixed(0)}%`, color: theme.colors.error };
+ }
+ return { text: `${capped.toFixed(0)}%`, color: theme.colors.success };
+}
+
+export function AccountRateMetrics({ rateMetrics, theme }: AccountRateMetricsProps) {
+ const trendConfig = {
+ up: { Icon: TrendingUp, label: 'Trending up', color: theme.colors.warning },
+ stable: { Icon: Minus, label: 'Stable', color: theme.colors.textDim },
+ down: { Icon: TrendingDown, label: 'Trending down', color: theme.colors.success },
+ };
+
+ const { Icon: TrendIcon, label: trendLabel, color: trendColor } = trendConfig[rateMetrics.trend];
+ const dailyDelta = formatDelta(rateMetrics.dailyDelta, theme);
+ const weeklyDelta = formatDelta(rateMetrics.weeklyDelta, theme);
+
+ return (
+
+ {/* Row 1: Rate metrics grid */}
+
+
+
Tokens/hr
+
+
+ {formatTokenCount(Math.round(rateMetrics.tokensPerHour))}
+
+
+
+
+
Tokens/day
+
+
+ {formatTokenCount(Math.round(rateMetrics.tokensPerDay))}
+
+
+ {dailyDelta.text}
+
+
+
+
+
Tokens/wk
+
+
+ {formatTokenCount(Math.round(rateMetrics.tokensPerWeek))}
+
+
+ {weeklyDelta.text}
+
+
+
+
+
+ {/* Row 2: Trend indicator */}
+
+
+ {trendLabel}
+
+
+ );
+}
diff --git a/src/renderer/components/UsageDashboard/AccountTrendChart.tsx b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
new file mode 100644
index 000000000..70b370bf3
--- /dev/null
+++ b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
@@ -0,0 +1,232 @@
+/**
+ * AccountTrendChart - SVG line chart for daily token usage.
+ * Supports full chart mode (axes, labels, tooltip) and compact sparkline mode.
+ */
+
+import React, { useState, useEffect, useMemo } from 'react';
+import type { Theme } from '../../types';
+import { formatTokenCount } from '../../hooks/useAccountUsage';
+
+interface DailyUsage {
+ date: string;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ cacheCreationTokens: number;
+ totalTokens: number;
+ costUsd: number;
+ queryCount: number;
+}
+
+interface AccountTrendChartProps {
+ accountId: string;
+ theme: Theme;
+ days?: number;
+ compact?: boolean;
+ limitTokensPerWindow?: number;
+}
+
+export function AccountTrendChart({
+ accountId,
+ theme,
+ days = 30,
+ compact = false,
+ limitTokensPerWindow,
+}: AccountTrendChartProps) {
+ const [data, setData] = useState([]);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const result = await window.maestro.accounts.getDailyUsage(accountId, days);
+ if (!cancelled) setData((result as DailyUsage[]) || []);
+ } catch (err) {
+ console.warn('[AccountTrendChart] Failed to fetch daily usage:', err);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, [accountId, days]);
+
+ const chart = useMemo(() => {
+ const width = compact ? 120 : 560;
+ const height = compact ? 24 : 160;
+ const paddingLeft = compact ? 0 : 48;
+ const paddingRight = compact ? 0 : 12;
+ const paddingTop = compact ? 2 : 16;
+ const paddingBottom = compact ? 2 : 24;
+ const chartWidth = width - paddingLeft - paddingRight;
+ const chartHeight = height - paddingTop - paddingBottom;
+ const maxTokens = Math.max(...data.map(d => d.totalTokens), 1);
+ const avgTokens = data.length > 0
+ ? data.reduce((s, d) => s + d.totalTokens, 0) / data.length
+ : 0;
+
+ const points = data.map((d, i) => {
+ const x = paddingLeft + (data.length > 1 ? (i / (data.length - 1)) * chartWidth : chartWidth / 2);
+ const y = paddingTop + chartHeight - (d.totalTokens / maxTokens) * chartHeight;
+ return { x, y, data: d };
+ });
+
+ const linePoints = points.map(p => `${p.x},${p.y}`).join(' ');
+ const areaPoints = `${points.map(p => `${p.x},${p.y}`).join(' ')} ${paddingLeft + chartWidth},${paddingTop + chartHeight} ${paddingLeft},${paddingTop + chartHeight}`;
+
+ return { width, height, paddingLeft, paddingTop, paddingBottom, chartWidth, chartHeight, maxTokens, avgTokens, points, linePoints, areaPoints };
+ }, [data, compact]);
+
+ if (data.length === 0) {
+ if (compact) {
+ return — ;
+ }
+ return (
+
+ No usage data
+
+ );
+ }
+
+ // Compact sparkline mode
+ if (compact) {
+ return (
+
+
+
+
+ );
+ }
+
+ // Full mode
+ const avgY = chart.paddingTop + chart.chartHeight - (chart.avgTokens / chart.maxTokens) * chart.chartHeight;
+ const hovered = hoveredIndex !== null ? chart.points[hoveredIndex] : null;
+
+ // X-axis date labels (first, middle, last)
+ const dateLabels: Array<{ x: number; label: string }> = [];
+ if (data.length > 0) {
+ const indices = [0, Math.floor(data.length / 2), data.length - 1];
+ for (const idx of indices) {
+ const d = data[idx];
+ const parts = d.date.split('-');
+ dateLabels.push({
+ x: chart.points[idx].x,
+ label: `${parseInt(parts[1])}/${parseInt(parts[2])}`,
+ });
+ }
+ }
+
+ return (
+ setHoveredIndex(null)}
+ >
+ {/* Area fill */}
+
+
+ {/* Average line */}
+
+
+ {/* Data line */}
+
+
+ {/* Limit threshold line */}
+ {limitTokensPerWindow != null && limitTokensPerWindow > 0 && (() => {
+ const limitY = chart.paddingTop + chart.chartHeight - (limitTokensPerWindow / chart.maxTokens) * chart.chartHeight;
+ if (limitY < chart.paddingTop) return null;
+ return (
+
+ );
+ })()}
+
+ {/* Y-axis labels */}
+
+ {formatTokenCount(chart.maxTokens)}
+
+
+ 0
+
+
+ {/* X-axis labels */}
+ {dateLabels.map((dl, i) => (
+
+ {dl.label}
+
+ ))}
+
+ {/* Hover rects */}
+ {chart.points.map((p, i) => (
+ setHoveredIndex(i)}
+ />
+ ))}
+
+ {/* Hover dot + tooltip */}
+ {hovered && hoveredIndex !== null && (
+ <>
+
+ 60 ? hovered.y - 52 : hovered.y + 8}
+ width={110}
+ height={44}
+ >
+
+
{hovered.data.date}
+
{formatTokenCount(hovered.data.totalTokens)} tokens
+
${hovered.data.costUsd.toFixed(2)}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx
index 2ddc413bb..96d028ed2 100644
--- a/src/renderer/components/VirtuosoUsageView.tsx
+++ b/src/renderer/components/VirtuosoUsageView.tsx
@@ -17,11 +17,14 @@ import {
TrendingUp,
Users,
Zap,
+ BarChart3,
} from 'lucide-react';
import type { Theme, Session } from '../types';
import type { AccountProfile } from '../../shared/account-types';
import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
import { AccountUsageHistory } from './AccountUsageHistory';
+import { AccountTrendChart } from './UsageDashboard/AccountTrendChart';
+import { AccountRateMetrics } from './UsageDashboard/AccountRateMetrics';
interface ThrottleEvent {
timestamp: number;
@@ -310,6 +313,18 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
~{formatTokenCount(Math.round(usage.burnRatePerHour))}/hr
+ {usage?.rateMetrics?.trend && usage.rateMetrics.trend !== 'stable' && (
+
+ {usage.rateMetrics.trend === 'up' ? '\u2197' : '\u2198'}
+
+ )}
{usage.estimatedTimeToLimitMs !== null && (
@@ -326,6 +341,13 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
+
+ {/* 7-day sparkline */}
+ {usage && (
+
+ )}
>
) : (
)}
+ {/* Section B.5: Trends & Analytics */}
+ {accounts.length > 0 && (
+
+
+
+ Trends & Analytics
+
+
+ {accounts.map((account) => {
+ const usage = metrics[account.id];
+ if (!usage) return null;
+ return (
+
+
+ {account.name || account.email}
+
+
+ {usage.rateMetrics && (
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
{/* Section C: Historical Usage */}
,
+ burnRatePerHour: number,
+): RateMetrics {
+ if (windowHistory.length === 0) {
+ return { ...EMPTY_RATE_METRICS, tokensPerHour: burnRatePerHour };
+ }
+
+ // ~5 windows/day, ~34 windows/week
+ const n = windowHistory.length;
+
+ // Tokens/day: sum of last 5 windows (~24h)
+ const daySlice = windowHistory.slice(Math.max(0, n - 5));
+ const tokensPerDay = daySlice.reduce((s, w) => s + w.totalTokens, 0);
+
+ // Tokens/week: sum of last 34 windows (~7 days)
+ const weekSlice = windowHistory.slice(Math.max(0, n - 34));
+ const tokensPerWeek = weekSlice.reduce((s, w) => s + w.totalTokens, 0);
+
+ // Daily delta: compare last 5 vs previous 5
+ const prevDaySlice = windowHistory.slice(Math.max(0, n - 10), Math.max(0, n - 5));
+ const prevDayTotal = prevDaySlice.reduce((s, w) => s + w.totalTokens, 0);
+ const dailyDelta = prevDayTotal > 0 ? ((tokensPerDay - prevDayTotal) / prevDayTotal) * 100 : 0;
+
+ // Weekly delta: compare last 34 vs previous 34
+ const prevWeekSlice = windowHistory.slice(Math.max(0, n - 68), Math.max(0, n - 34));
+ const prevWeekTotal = prevWeekSlice.reduce((s, w) => s + w.totalTokens, 0);
+ const weeklyDelta = prevWeekTotal > 0 ? ((tokensPerWeek - prevWeekTotal) / prevWeekTotal) * 100 : 0;
+
+ // Trend: linear regression on last 20 windows
+ const trendSlice = windowHistory.slice(Math.max(0, n - 20));
+ let trend: 'up' | 'stable' | 'down' = 'stable';
+ if (trendSlice.length >= 2) {
+ const tLen = trendSlice.length;
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
+ for (let i = 0; i < tLen; i++) {
+ sumX += i;
+ sumY += trendSlice[i].totalTokens;
+ sumXY += i * trendSlice[i].totalTokens;
+ sumX2 += i * i;
+ }
+ const denom = tLen * sumX2 - sumX * sumX;
+ if (denom !== 0) {
+ const slope = (tLen * sumXY - sumX * sumY) / denom;
+ const meanY = sumY / tLen;
+ const normalizedSlope = meanY > 0 ? (slope * tLen) / meanY : 0;
+ if (normalizedSlope > 0.05) trend = 'up';
+ else if (normalizedSlope < -0.05) trend = 'down';
+ }
+ }
+
+ return {
+ tokensPerHour: burnRatePerHour,
+ tokensPerDay,
+ tokensPerWeek,
+ dailyDelta,
+ weeklyDelta,
+ trend,
+ };
+}
+
// ============================================================================
// Hook
// ============================================================================
@@ -210,12 +302,19 @@ export function useAccountUsage(): {
raw.windowEnd - raw.windowStart,
);
+ // Rate metrics from window history
+ const rateMetrics = calculateRateMetrics(
+ windowHistoriesRef.current[raw.accountId] || [],
+ burnRatePerHour,
+ );
+
return {
...raw,
timeRemainingMs,
burnRatePerHour,
estimatedTimeToLimitMs,
prediction,
+ rateMetrics,
};
}, []);
@@ -280,7 +379,7 @@ export function useAccountUsage(): {
const histories: Record> = {};
for (const account of (accounts || []) as Array<{ id: string }>) {
try {
- const history = await window.maestro.accounts.getWindowHistory(account.id, 40) as Array<{
+ const history = await window.maestro.accounts.getWindowHistory(account.id, 68) as Array<{
inputTokens: number; outputTokens: number;
cacheReadTokens: number; cacheCreationTokens: number;
windowStart: number; windowEnd: number;
From df0488e6bec5a356c1f72235815a2ae851a43b5b Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 02:34:34 -0500
Subject: [PATCH 29/59] MAESTRO: fix: remove unused React import from
AccountRateMetrics component
Removes the unnecessary default React import that caused TS6133 since
the project uses the modern JSX transform.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/components/UsageDashboard/AccountRateMetrics.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx b/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx
index 9a968aa2b..68e7f3c7d 100644
--- a/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx
+++ b/src/renderer/components/UsageDashboard/AccountRateMetrics.tsx
@@ -3,7 +3,6 @@
* at three time scales with period-over-period deltas and trend indicator.
*/
-import React from 'react';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { Theme } from '../../types';
import type { RateMetrics } from '../../hooks/useAccountUsage';
From e5fadc3a5f74190b4c4e1f0dbe63b2f75e748800 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 02:38:06 -0500
Subject: [PATCH 30/59] MAESTRO: feat: add predictions, sparklines, and trend
indicators to AccountUsageDashboard
Integrates the useAccountUsage hook into the main Usage Dashboard's Accounts tab
to surface burn rate, TTL, prediction confidence, 7-day sparklines, and daily trend
arrows alongside existing overview cards and bar charts.
Co-Authored-By: Claude Opus 4.6
---
.../UsageDashboard/AccountUsageDashboard.tsx | 74 ++++++++++++++++++-
1 file changed, 72 insertions(+), 2 deletions(-)
diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
index 6221d289d..0d2cd5ad9 100644
--- a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
+++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
@@ -14,7 +14,10 @@ import {
Zap,
TrendingUp,
ArrowRightLeft,
+ Clock,
} from 'lucide-react';
+import { useAccountUsage } from '../../hooks/useAccountUsage';
+import { AccountTrendChart } from './AccountTrendChart';
import type { Theme, Session } from '../../types';
import type {
AccountProfile,
@@ -25,12 +28,13 @@ import type {
interface AccountUsageDashboardProps {
theme: Theme;
- sessions: Session[];
+ sessions?: Session[];
onClose: () => void;
}
/** Format token counts with K/M suffixes */
function formatTokens(n: number): string {
+ if (n == null || isNaN(n)) return '0';
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toString();
@@ -38,6 +42,7 @@ function formatTokens(n: number): string {
/** Format cost in USD */
function formatCost(usd: number): string {
+ if (usd == null || isNaN(usd)) return '$0.00';
if (usd === 0) return '$0.00';
if (usd < 0.01) return '<$0.01';
return `$${usd.toFixed(2)}`;
@@ -81,13 +86,14 @@ interface ThrottleEvent {
recoveryAction?: string;
}
-export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboardProps) {
+export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDashboardProps) {
const [accounts, setAccounts] = useState([]);
const [usageData, setUsageData] = useState>({});
const [assignments, setAssignments] = useState([]);
const [throttleEvents, setThrottleEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const { metrics: accountMetrics } = useAccountUsage();
// Fetch all data on mount
const fetchData = useCallback(async () => {
@@ -137,6 +143,10 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
[data.accountId]: {
...defaults,
...prev[data.accountId],
+ inputTokens: data.inputTokens,
+ outputTokens: data.outputTokens,
+ cacheReadTokens: data.cacheReadTokens,
+ cacheCreationTokens: data.cacheCreationTokens,
costUsd: data.costUsd,
windowStart: data.windowStart,
windowEnd: data.windowEnd,
@@ -325,6 +335,11 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
+ {/* Sparkline */}
+
+
{/* Stats grid */}
@@ -346,6 +361,50 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
+
+ {/* Prediction row */}
+ {(() => {
+ const acctMetrics = accountMetrics[account.id];
+ if (!acctMetrics || acctMetrics.burnRatePerHour <= 0) return null;
+ return (
+
+
+
Burn
+
+ ~{formatTokens(Math.round(acctMetrics.burnRatePerHour))}/hr
+
+
+
+
TTL
+
+ {acctMetrics.estimatedTimeToLimitMs !== null
+ ? formatTimeRemaining(acctMetrics.estimatedTimeToLimitMs)
+ : '—'}
+
+
+
+
Confidence
+
+ {acctMetrics.prediction.confidence}
+
+
+
+ );
+ })()}
);
})}
@@ -496,6 +555,17 @@ export function AccountUsageDashboard({ theme, sessions }: AccountUsageDashboard
/>
)}
+ {(() => {
+ const m = accountMetrics[account.id];
+ if (!m?.rateMetrics) return null;
+ const trendSymbol = m.rateMetrics.trend === 'up' ? '\u2197' : m.rateMetrics.trend === 'down' ? '\u2198' : '\u2192';
+ const trendColor = m.rateMetrics.trend === 'up' ? theme.colors.warning : m.rateMetrics.trend === 'down' ? theme.colors.success : theme.colors.textDim;
+ return (
+
+ {trendSymbol} {formatTokens(Math.round(m.rateMetrics.tokensPerDay))}/day
+
+ );
+ })()}
);
})}
From f5172ef46cc67b95c686d8733881943359f3d252 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 02:50:33 -0500
Subject: [PATCH 31/59] MAESTRO: fix: resolve session ID mismatch in account
usage/error listeners and token data pipeline
The account-usage-listener and error-listener failed to find account
assignments because usage/error events arrive with compound session IDs
(e.g. "{id}-ai-{tabId}") while assignments were keyed by base session
ID from reconcileSessions on app restore. Added fallback lookup that
strips the suffix to find the base-keyed assignment.
Also: consolidated duplicated session ID suffix regex into a shared
REGEX_SESSION_SUFFIX constant (now includes -terminal), wired token
data through the recordQuery pipeline (stats-listener, ExitHandler,
useAgentListeners, useAgentExecution), removed debug console.logs
from StdoutHandler, and fixed toggle off-state color in AccountsPanel
to use bgActivity instead of border.
Co-Authored-By: Claude Opus 4.6
---
src/main/constants.ts | 4 ++
src/main/ipc/handlers/process.ts | 7 +++-
src/main/preload/stats.ts | 5 +++
.../account-usage-listener.ts | 37 ++++++++++++++-----
src/main/process-listeners/error-listener.ts | 12 +++++-
src/main/process-listeners/exit-listener.ts | 6 +--
src/main/process-listeners/stats-listener.ts | 5 +++
.../process-manager/handlers/ExitHandler.ts | 4 ++
.../process-manager/handlers/StdoutHandler.ts | 27 --------------
src/main/process-manager/types.ts | 5 +++
src/renderer/components/AccountsPanel.tsx | 6 +--
src/renderer/components/AppModals.tsx | 2 +-
src/renderer/global.d.ts | 5 +++
src/renderer/hooks/agent/useAgentExecution.ts | 5 +++
src/renderer/hooks/agent/useAgentListeners.ts | 5 +++
15 files changed, 89 insertions(+), 46 deletions(-)
diff --git a/src/main/constants.ts b/src/main/constants.ts
index 7ea4307dd..490fefb21 100644
--- a/src/main/constants.ts
+++ b/src/main/constants.ts
@@ -32,6 +32,10 @@ export const REGEX_AI_TAB_ID = /-ai-(.+)$/;
export const REGEX_BATCH_SESSION = /-batch-\d+$/;
export const REGEX_SYNOPSIS_SESSION = /-synopsis-\d+$/;
+// Combined pattern to strip all session ID suffixes back to the base session ID.
+// Matches: -ai-{tabId}, -terminal, -batch-{timestamp}, -synopsis-{timestamp}
+export const REGEX_SESSION_SUFFIX = /-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/;
+
// ============================================================================
// Buffer Size Limits
// ============================================================================
diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts
index 9f06fd56d..8c05b4a6e 100644
--- a/src/main/ipc/handlers/process.ts
+++ b/src/main/ipc/handlers/process.ts
@@ -28,6 +28,7 @@ import { buildSshCommandWithStdin } from '../../utils/ssh-command-builder';
import { buildStreamJsonMessage } from '../../process-manager/utils/streamJsonBuilder';
import { getWindowsShellForAgentExecution } from '../../process-manager/utils/shellEscape';
import { buildExpandedEnv } from '../../../shared/pathUtils';
+import { REGEX_SESSION_SUFFIX } from '../../constants';
import type { SshRemoteConfig } from '../../../shared/types';
import { powerManager } from '../../power-manager';
import { MaestroSettings } from './persistence';
@@ -298,8 +299,12 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
const registry = getAccountRegistry?.();
if (registry) {
const envToInject: Record = customEnvVarsToPass ? { ...customEnvVarsToPass } : {};
+ // Use base session ID for assignment (strip -ai-{tabId} etc.) so
+ // assignments are keyed consistently regardless of spawn vs restore.
+ const baseSessionIdForAccount = config.sessionId
+ .replace(REGEX_SESSION_SUFFIX, '');
const assignedAccountId = injectAccountEnv(
- config.sessionId,
+ baseSessionIdForAccount,
config.toolType,
envToInject,
registry,
diff --git a/src/main/preload/stats.ts b/src/main/preload/stats.ts
index 4c44b5ca8..86f172f83 100644
--- a/src/main/preload/stats.ts
+++ b/src/main/preload/stats.ts
@@ -21,6 +21,11 @@ export interface QueryEvent {
projectPath?: string;
tabId?: string;
isRemote?: boolean;
+ inputTokens?: number;
+ outputTokens?: number;
+ cacheReadTokens?: number;
+ cacheCreationTokens?: number;
+ costUsd?: number;
}
/**
diff --git a/src/main/process-listeners/account-usage-listener.ts b/src/main/process-listeners/account-usage-listener.ts
index f959e55f6..14b43e2ad 100644
--- a/src/main/process-listeners/account-usage-listener.ts
+++ b/src/main/process-listeners/account-usage-listener.ts
@@ -10,6 +10,7 @@ import type { StatsDB } from '../stats';
import type { UsageStats } from './types';
import { DEFAULT_TOKEN_WINDOW_MS } from '../../shared/account-types';
import { getWindowBounds } from '../accounts/account-utils';
+import { REGEX_SESSION_SUFFIX } from '../constants';
const LOG_CONTEXT = 'account-usage-listener';
@@ -38,30 +39,48 @@ export function setupAccountUsageListener(
processManager.on('usage', (sessionId: string, usageStats: UsageStats) => {
try {
const accountRegistry = getAccountRegistry();
- if (!accountRegistry) return; // Account system not initialized
+ if (!accountRegistry) {
+ return;
+ }
- // Look up the account assigned to this session
- const assignment = accountRegistry.getAssignment(sessionId);
- if (!assignment) return; // No account assigned — skip
+ // Usage events arrive with compound session IDs (e.g. "{id}-ai-{tabId}")
+ // but assignments may be keyed by base session ID (from reconcileSessions on restore).
+ // Try compound ID first (from spawn-time assignment), then base ID (from restore).
+ let assignment = accountRegistry.getAssignment(sessionId);
+ if (!assignment) {
+ const baseSessionId = sessionId.replace(REGEX_SESSION_SUFFIX, '');
+ if (baseSessionId !== sessionId) {
+ assignment = accountRegistry.getAssignment(baseSessionId);
+ }
+ }
+ if (!assignment) {
+ return;
+ }
const account = accountRegistry.get(assignment.accountId);
- if (!account) return; // Account was deleted — skip
+ if (!account) {
+ return;
+ }
const statsDb = getStatsDB();
- if (!statsDb.isReady()) return; // Stats DB not ready
+ if (!statsDb.isReady()) {
+ return;
+ }
const windowMs = account.tokenWindowMs || DEFAULT_TOKEN_WINDOW_MS;
const now = Date.now();
const { start, end } = getWindowBounds(now, windowMs);
- // Aggregate tokens into the account's current window
- statsDb.upsertAccountUsageWindow(account.id, start, end, {
+ const tokensToWrite = {
inputTokens: usageStats.inputTokens || 0,
outputTokens: usageStats.outputTokens || 0,
cacheReadTokens: usageStats.cacheReadInputTokens || 0,
cacheCreationTokens: usageStats.cacheCreationInputTokens || 0,
costUsd: usageStats.totalCostUsd || 0,
- });
+ };
+
+ // Aggregate tokens into the account's current window
+ statsDb.upsertAccountUsageWindow(account.id, start, end, tokensToWrite);
// Read back aggregated window usage and broadcast to renderer
const windowUsage = statsDb.getAccountUsageInWindow(account.id, start, end);
diff --git a/src/main/process-listeners/error-listener.ts b/src/main/process-listeners/error-listener.ts
index 5094aecdb..9f4e5eba2 100644
--- a/src/main/process-listeners/error-listener.ts
+++ b/src/main/process-listeners/error-listener.ts
@@ -12,6 +12,7 @@ import type { ProcessListenerDependencies } from './types';
import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
import type { AccountAuthRecovery } from '../accounts/account-auth-recovery';
import type { AccountRegistry } from '../accounts/account-registry';
+import { REGEX_SESSION_SUFFIX } from '../constants';
/**
* Sets up the agent-error listener.
@@ -45,7 +46,16 @@ export function setupErrorListener(
const accountRegistry = accountDeps.getAccountRegistry();
if (!accountRegistry) return;
- const assignment = accountRegistry.getAssignment(sessionId);
+ // Try compound session ID first, then fall back to base session ID.
+ // Assignments may be keyed by base ID (from reconcileSessions on restore)
+ // while error events arrive with compound IDs (e.g. "{id}-ai-{tabId}").
+ let assignment = accountRegistry.getAssignment(sessionId);
+ if (!assignment) {
+ const baseSessionId = sessionId.replace(REGEX_SESSION_SUFFIX, '');
+ if (baseSessionId !== sessionId) {
+ assignment = accountRegistry.getAssignment(baseSessionId);
+ }
+ }
if (!assignment) return;
if (agentError.type === 'auth_expired') {
diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts
index dc70000bc..280ce6425 100644
--- a/src/main/process-listeners/exit-listener.ts
+++ b/src/main/process-listeners/exit-listener.ts
@@ -6,6 +6,7 @@
import type { ProcessManager } from '../process-manager';
import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types';
+import { REGEX_SESSION_SUFFIX } from '../constants';
/**
* Sets up the exit listener for process termination.
@@ -433,10 +434,7 @@ export function setupExitListener(
const webServer = getWebServer();
if (webServer) {
// Extract base session ID from formats: {id}-ai-{tabId}, {id}-terminal, {id}-batch-{timestamp}, {id}-synopsis-{timestamp}
- const baseSessionId = sessionId.replace(
- /-ai-.+$|-terminal$|-batch-\d+$|-synopsis-\d+$/,
- ''
- );
+ const baseSessionId = sessionId.replace(REGEX_SESSION_SUFFIX, '');
webServer.broadcastToSessionClients(baseSessionId, {
type: 'session_exit',
sessionId: baseSessionId,
diff --git a/src/main/process-listeners/stats-listener.ts b/src/main/process-listeners/stats-listener.ts
index b37a8c18d..ccf7caf03 100644
--- a/src/main/process-listeners/stats-listener.ts
+++ b/src/main/process-listeners/stats-listener.ts
@@ -36,6 +36,11 @@ async function insertQueryEventWithRetry(
duration: queryData.duration,
projectPath: queryData.projectPath,
tabId: queryData.tabId,
+ inputTokens: queryData.inputTokens,
+ outputTokens: queryData.outputTokens,
+ cacheReadTokens: queryData.cacheReadTokens,
+ cacheCreationTokens: queryData.cacheCreationTokens,
+ costUsd: queryData.costUsd,
});
return id;
} catch (error) {
diff --git a/src/main/process-manager/handlers/ExitHandler.ts b/src/main/process-manager/handlers/ExitHandler.ts
index 985838ce8..c0d0b04c5 100644
--- a/src/main/process-manager/handlers/ExitHandler.ts
+++ b/src/main/process-manager/handlers/ExitHandler.ts
@@ -229,6 +229,10 @@ export class ExitHandler {
duration,
projectPath: managedProcess.projectPath,
tabId: managedProcess.tabId,
+ inputTokens: managedProcess.lastUsageTotals?.inputTokens,
+ outputTokens: managedProcess.lastUsageTotals?.outputTokens,
+ cacheReadTokens: managedProcess.lastUsageTotals?.cacheReadInputTokens,
+ cacheCreationTokens: managedProcess.lastUsageTotals?.cacheCreationInputTokens,
});
logger.debug('[ProcessManager] Query complete event emitted', 'ProcessManager', {
sessionId,
diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts
index cc527aa1c..060a2ef2d 100644
--- a/src/main/process-manager/handlers/StdoutHandler.ts
+++ b/src/main/process-manager/handlers/StdoutHandler.ts
@@ -237,13 +237,6 @@ export class StdoutHandler {
// Extract usage
const usage = outputParser.extractUsage(event);
if (usage) {
- // DEBUG: Log usage extracted from parser
- console.log('[StdoutHandler] Usage from parser (line 255 path)', {
- sessionId,
- toolType: managedProcess.toolType,
- parsedUsage: usage,
- });
-
const usageStats = this.buildUsageStats(managedProcess, usage);
// Claude Code's modelUsage reports the ACTUAL context used for each API call:
// - inputTokens: new input for this turn
@@ -259,12 +252,6 @@ export class StdoutHandler {
? normalizeUsageToDelta(managedProcess, usageStats)
: usageStats;
- // DEBUG: Log normalized stats being emitted
- console.log('[StdoutHandler] Emitting usage (line 255 path)', {
- sessionId,
- normalizedUsageStats,
- });
-
this.emitter.emit('usage', sessionId, normalizedUsageStats);
}
@@ -402,26 +389,12 @@ export class StdoutHandler {
}
if (msgRecord.modelUsage || msgRecord.usage || msgRecord.total_cost_usd !== undefined) {
- // DEBUG: Log raw usage data from Claude Code before aggregation
- console.log('[StdoutHandler] Raw usage data from Claude Code', {
- sessionId,
- modelUsage: msgRecord.modelUsage,
- usage: msgRecord.usage,
- totalCostUsd: msgRecord.total_cost_usd,
- });
-
const usageStats = aggregateModelUsage(
msgRecord.modelUsage as Record | undefined,
(msgRecord.usage as Record) || {},
(msgRecord.total_cost_usd as number) || 0
);
- // DEBUG: Log aggregated result
- console.log('[StdoutHandler] Aggregated usage stats', {
- sessionId,
- usageStats,
- });
-
this.emitter.emit('usage', sessionId, usageStats);
}
}
diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts
index 6b2ccb03a..8f9b62b06 100644
--- a/src/main/process-manager/types.ts
+++ b/src/main/process-manager/types.ts
@@ -134,6 +134,11 @@ export interface QueryCompleteData {
duration: number;
projectPath?: string;
tabId?: string;
+ inputTokens?: number;
+ outputTokens?: number;
+ cacheReadTokens?: number;
+ cacheCreationTokens?: number;
+ costUsd?: number;
}
// Re-export for backwards compatibility
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index ee73f7e6b..ef0526e01 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -782,7 +782,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
style={{
backgroundColor: account.autoSwitchEnabled
? theme.colors.accent
- : theme.colors.border,
+ : theme.colors.bgActivity,
}}
>
)}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 0f9309235..f3981449c 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2118,6 +2118,11 @@ interface MaestroAPI {
projectPath?: string;
tabId?: string;
isRemote?: boolean;
+ inputTokens?: number;
+ outputTokens?: number;
+ cacheReadTokens?: number;
+ cacheCreationTokens?: number;
+ costUsd?: number;
}) => Promise
;
// Start an Auto Run session (returns session ID)
startAutoRun: (session: {
diff --git a/src/renderer/hooks/agent/useAgentExecution.ts b/src/renderer/hooks/agent/useAgentExecution.ts
index 553d6018b..49380e583 100644
--- a/src/renderer/hooks/agent/useAgentExecution.ts
+++ b/src/renderer/hooks/agent/useAgentExecution.ts
@@ -251,6 +251,11 @@ export function useAgentExecution(deps: UseAgentExecutionDeps): UseAgentExecutio
projectPath: effectiveCwd,
tabId: activeTab?.id,
isRemote: session.sessionSshRemoteConfig?.enabled ?? false,
+ inputTokens: taskUsageStats?.inputTokens,
+ outputTokens: taskUsageStats?.outputTokens,
+ cacheReadTokens: taskUsageStats?.cacheReadInputTokens,
+ cacheCreationTokens: taskUsageStats?.cacheCreationInputTokens,
+ costUsd: taskUsageStats?.totalCostUsd,
})
.catch((err) => {
// Don't fail the batch flow if stats recording fails
diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts
index f637d413a..891dd539b 100644
--- a/src/renderer/hooks/agent/useAgentListeners.ts
+++ b/src/renderer/hooks/agent/useAgentListeners.ts
@@ -745,6 +745,11 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void {
projectPath: toastData.projectPath,
tabId: toastData.tabId,
isRemote: toastData.isRemote,
+ inputTokens: toastData.usageStats?.inputTokens,
+ outputTokens: toastData.usageStats?.outputTokens,
+ cacheReadTokens: toastData.usageStats?.cacheReadInputTokens,
+ cacheCreationTokens: toastData.usageStats?.cacheCreationInputTokens,
+ costUsd: toastData.usageStats?.totalCostUsd,
})
.catch((err) => {
console.warn('[onProcessExit] Failed to record query stats:', err);
From bc76b95481fff2867fa39938f272b2ba8099617b Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 03:03:54 -0500
Subject: [PATCH 32/59] MAESTRO: feat: add time range toggles
(24h/7d/30d/monthly) to AccountTrendChart sparklines
Rewrote AccountTrendChart to support switchable time ranges with pill toggles
in both compact sparkline and full chart modes. Each range fetches from the
appropriate data source (billing windows for 24h, daily aggregation for 7d/30d,
monthly aggregation for monthly). Updated all callers in VirtuosoUsageView and
AccountUsageDashboard to use the new defaultRange prop.
Co-Authored-By: Claude Opus 4.6
---
.../UsageDashboard/AccountTrendChart.tsx | 401 ++++++++++++------
.../UsageDashboard/AccountUsageDashboard.tsx | 2 +-
src/renderer/components/VirtuosoUsageView.tsx | 4 +-
3 files changed, 269 insertions(+), 138 deletions(-)
diff --git a/src/renderer/components/UsageDashboard/AccountTrendChart.tsx b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
index 70b370bf3..b00004924 100644
--- a/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
+++ b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
@@ -1,53 +1,152 @@
/**
- * AccountTrendChart - SVG line chart for daily token usage.
- * Supports full chart mode (axes, labels, tooltip) and compact sparkline mode.
+ * AccountTrendChart - SVG line chart for account token usage over time.
+ * Supports full chart mode (axes, labels, tooltip, time range toggles)
+ * and compact sparkline mode (with small pill toggles).
+ *
+ * Time ranges:
+ * - 24h: billing window history (~5-hour granularity)
+ * - 7d / 30d: daily aggregation
+ * - Monthly: monthly aggregation
*/
-import React, { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo } from 'react';
import type { Theme } from '../../types';
import { formatTokenCount } from '../../hooks/useAccountUsage';
-interface DailyUsage {
- date: string;
+type TimeRange = '24h' | '7d' | '30d' | 'monthly';
+
+const TIME_RANGE_LABELS: Record = {
+ '24h': '24h',
+ '7d': '7d',
+ '30d': '30d',
+ 'monthly': 'Mo',
+};
+
+interface DataPoint {
+ label: string;
+ totalTokens: number;
+ costUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
- totalTokens: number;
- costUsd: number;
- queryCount: number;
}
interface AccountTrendChartProps {
accountId: string;
theme: Theme;
- days?: number;
+ /** Default time range to display */
+ defaultRange?: TimeRange;
compact?: boolean;
limitTokensPerWindow?: number;
}
+/**
+ * Fetch data for the selected time range.
+ * Returns a normalized array of DataPoint regardless of source.
+ */
+async function fetchRangeData(accountId: string, range: TimeRange): Promise {
+ if (range === '24h') {
+ // Use billing window history (5-hour windows)
+ const windows = await window.maestro.accounts.getWindowHistory(accountId, 10) as Array<{
+ windowStart: number; windowEnd: number;
+ inputTokens: number; outputTokens: number;
+ cacheReadTokens: number; cacheCreationTokens: number;
+ costUsd: number;
+ }>;
+ return (windows || []).map(w => {
+ const d = new Date(w.windowStart);
+ const hours = d.getHours();
+ const label = `${d.getMonth() + 1}/${d.getDate()} ${hours}:00`;
+ return {
+ label,
+ totalTokens: w.inputTokens + w.outputTokens + w.cacheReadTokens + w.cacheCreationTokens,
+ costUsd: w.costUsd,
+ inputTokens: w.inputTokens,
+ outputTokens: w.outputTokens,
+ cacheReadTokens: w.cacheReadTokens,
+ cacheCreationTokens: w.cacheCreationTokens,
+ };
+ });
+ }
+
+ if (range === 'monthly') {
+ const monthly = await window.maestro.accounts.getMonthlyUsage(accountId, 6) as Array<{
+ month: string;
+ inputTokens: number; outputTokens: number;
+ cacheReadTokens: number; cacheCreationTokens: number;
+ totalTokens: number; costUsd: number;
+ }>;
+ return (monthly || []).map(m => ({
+ label: m.month,
+ totalTokens: m.totalTokens,
+ costUsd: m.costUsd,
+ inputTokens: m.inputTokens,
+ outputTokens: m.outputTokens,
+ cacheReadTokens: m.cacheReadTokens,
+ cacheCreationTokens: m.cacheCreationTokens,
+ }));
+ }
+
+ // 7d or 30d — daily aggregation
+ const days = range === '7d' ? 7 : 30;
+ const daily = await window.maestro.accounts.getDailyUsage(accountId, days) as Array<{
+ date: string;
+ inputTokens: number; outputTokens: number;
+ cacheReadTokens: number; cacheCreationTokens: number;
+ totalTokens: number; costUsd: number;
+ }>;
+ return (daily || []).map(d => ({
+ label: d.date,
+ totalTokens: d.totalTokens,
+ costUsd: d.costUsd,
+ inputTokens: d.inputTokens,
+ outputTokens: d.outputTokens,
+ cacheReadTokens: d.cacheReadTokens,
+ cacheCreationTokens: d.cacheCreationTokens,
+ }));
+}
+
+/** Format label for x-axis display based on time range */
+function formatXLabel(label: string, range: TimeRange): string {
+ if (range === '24h') {
+ // Already formatted as "M/D HH:00"
+ return label;
+ }
+ if (range === 'monthly') {
+ // "YYYY-MM" → "Jan 26"
+ const [year, month] = label.split('-');
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ return `${months[parseInt(month, 10) - 1]} ${year.slice(2)}`;
+ }
+ // Daily: "YYYY-MM-DD" → "M/D"
+ const parts = label.split('-');
+ return `${parseInt(parts[1])}/${parseInt(parts[2])}`;
+}
+
export function AccountTrendChart({
accountId,
theme,
- days = 30,
+ defaultRange = '7d',
compact = false,
limitTokensPerWindow,
}: AccountTrendChartProps) {
- const [data, setData] = useState([]);
+ const [range, setRange] = useState(defaultRange);
+ const [data, setData] = useState([]);
const [hoveredIndex, setHoveredIndex] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
- const result = await window.maestro.accounts.getDailyUsage(accountId, days);
- if (!cancelled) setData((result as DailyUsage[]) || []);
+ const result = await fetchRangeData(accountId, range);
+ if (!cancelled) setData(result);
} catch (err) {
- console.warn('[AccountTrendChart] Failed to fetch daily usage:', err);
+ console.warn('[AccountTrendChart] Failed to fetch usage data:', err);
}
})();
return () => { cancelled = true; };
- }, [accountId, days]);
+ }, [accountId, range]);
const chart = useMemo(() => {
const width = compact ? 120 : 560;
@@ -75,16 +174,42 @@ export function AccountTrendChart({
return { width, height, paddingLeft, paddingTop, paddingBottom, chartWidth, chartHeight, maxTokens, avgTokens, points, linePoints, areaPoints };
}, [data, compact]);
+ const rangeToggle = (small: boolean) => (
+
+ {(Object.keys(TIME_RANGE_LABELS) as TimeRange[]).map(r => (
+ setRange(r)}
+ className={`${small ? 'text-[8px] px-1 py-0' : 'text-[10px] px-1.5 py-0.5'} rounded ${range === r ? 'font-bold' : ''}`}
+ style={{
+ backgroundColor: range === r ? theme.colors.accent + '20' : 'transparent',
+ color: range === r ? theme.colors.accent : theme.colors.textDim,
+ }}
+ >
+ {TIME_RANGE_LABELS[r]}
+
+ ))}
+
+ );
+
if (data.length === 0) {
if (compact) {
- return — ;
+ return (
+
+ {rangeToggle(true)}
+ —
+
+ );
}
return (
-
- No usage data
+
+ {rangeToggle(false)}
+
+ No usage data
+
);
}
@@ -92,16 +217,19 @@ export function AccountTrendChart({
// Compact sparkline mode
if (compact) {
return (
-
-
-
-
+
+ {rangeToggle(true)}
+
+
+
+
+
);
}
@@ -112,121 +240,124 @@ export function AccountTrendChart({
// X-axis date labels (first, middle, last)
const dateLabels: Array<{ x: number; label: string }> = [];
if (data.length > 0) {
- const indices = [0, Math.floor(data.length / 2), data.length - 1];
+ const indices = data.length <= 2
+ ? data.map((_, i) => i)
+ : [0, Math.floor(data.length / 2), data.length - 1];
for (const idx of indices) {
- const d = data[idx];
- const parts = d.date.split('-');
dateLabels.push({
x: chart.points[idx].x,
- label: `${parseInt(parts[1])}/${parseInt(parts[2])}`,
+ label: formatXLabel(data[idx].label, range),
});
}
}
return (
-
setHoveredIndex(null)}
- >
- {/* Area fill */}
-
-
- {/* Average line */}
-
-
- {/* Data line */}
-
-
- {/* Limit threshold line */}
- {limitTokensPerWindow != null && limitTokensPerWindow > 0 && (() => {
- const limitY = chart.paddingTop + chart.chartHeight - (limitTokensPerWindow / chart.maxTokens) * chart.chartHeight;
- if (limitY < chart.paddingTop) return null;
- return (
-
- );
- })()}
-
- {/* Y-axis labels */}
-
- {formatTokenCount(chart.maxTokens)}
-
-
- 0
-
-
- {/* X-axis labels */}
- {dateLabels.map((dl, i) => (
-
- {dl.label}
-
- ))}
+
+ {rangeToggle(false)}
+
setHoveredIndex(null)}
+ >
+ {/* Area fill */}
+
- {/* Hover rects */}
- {chart.points.map((p, i) => (
- setHoveredIndex(i)}
+ {/* Average line */}
+
- ))}
- {/* Hover dot + tooltip */}
- {hovered && hoveredIndex !== null && (
- <>
-
- 60 ? hovered.y - 52 : hovered.y + 8}
- width={110}
- height={44}
- >
-
+
+ {/* Limit threshold line */}
+ {limitTokensPerWindow != null && limitTokensPerWindow > 0 && (() => {
+ const limitY = chart.paddingTop + chart.chartHeight - (limitTokensPerWindow / chart.maxTokens) * chart.chartHeight;
+ if (limitY < chart.paddingTop) return null;
+ return (
+
+ );
+ })()}
+
+ {/* Y-axis labels */}
+
+ {formatTokenCount(chart.maxTokens)}
+
+
+ 0
+
+
+ {/* X-axis labels */}
+ {dateLabels.map((dl, i) => (
+
+ {dl.label}
+
+ ))}
+
+ {/* Hover rects */}
+ {chart.points.map((p, i) => (
+ setHoveredIndex(i)}
+ />
+ ))}
+
+ {/* Hover dot + tooltip */}
+ {hovered && hoveredIndex !== null && (
+ <>
+
+ 60 ? hovered.y - 52 : hovered.y + 8}
+ width={110}
+ height={44}
>
- {hovered.data.date}
- {formatTokenCount(hovered.data.totalTokens)} tokens
- ${hovered.data.costUsd.toFixed(2)}
-
-
- >
- )}
-
+
+
{formatXLabel(hovered.data.label, range)}
+
{formatTokenCount(hovered.data.totalTokens)} tokens
+
${hovered.data.costUsd.toFixed(2)}
+
+
+ >
+ )}
+
+
);
}
diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
index 0d2cd5ad9..2ff0e744a 100644
--- a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
+++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
@@ -337,7 +337,7 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{/* Sparkline */}
{/* Stats grid */}
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx
index 96d028ed2..f26ef92a7 100644
--- a/src/renderer/components/VirtuosoUsageView.tsx
+++ b/src/renderer/components/VirtuosoUsageView.tsx
@@ -345,7 +345,7 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
{/* 7-day sparkline */}
{usage && (
)}
>
@@ -515,7 +515,7 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
>
{account.name || account.email}
-
+
{usage.rateMetrics && (
From bb4d94cb812f3c457c3a3af6c72a0af404304704 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Mon, 16 Feb 2026 03:10:44 -0500
Subject: [PATCH 33/59] MAESTRO: fix: replace dynamic Tailwind classes with
inline styles in AccountTrendChart range toggles
Tailwind JIT cannot detect dynamically constructed class names, so the
toggle buttons were invisible. Switched to inline style props with
visible bgActivity background for unselected state and bumped compact
font size from 8px to 9px.
Co-Authored-By: Claude Opus 4.6
---
.../UsageDashboard/AccountTrendChart.tsx | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/renderer/components/UsageDashboard/AccountTrendChart.tsx b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
index b00004924..6d6a57cc1 100644
--- a/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
+++ b/src/renderer/components/UsageDashboard/AccountTrendChart.tsx
@@ -175,15 +175,25 @@ export function AccountTrendChart({
}, [data, compact]);
const rangeToggle = (small: boolean) => (
-
+
{(Object.keys(TIME_RANGE_LABELS) as TimeRange[]).map(r => (
setRange(r)}
- className={`${small ? 'text-[8px] px-1 py-0' : 'text-[10px] px-1.5 py-0.5'} rounded ${range === r ? 'font-bold' : ''}`}
style={{
- backgroundColor: range === r ? theme.colors.accent + '20' : 'transparent',
+ fontSize: small ? 9 : 10,
+ padding: small ? '1px 4px' : '2px 6px',
+ borderRadius: 3,
+ border: 'none',
+ cursor: 'pointer',
+ fontWeight: range === r ? 700 : 400,
+ backgroundColor: range === r ? theme.colors.accent + '25' : theme.colors.bgActivity,
color: range === r ? theme.colors.accent : theme.colors.textDim,
+ lineHeight: 1.2,
}}
>
{TIME_RANGE_LABELS[r]}
From e7629a4a13a1a4c469d62331fba35a83ea0b1b5c Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 01:02:19 -0500
Subject: [PATCH 34/59] feat: gate Virtuosos behind Encore Features toggle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Virtuosos (multi-account multiplexing) is now an opt-in Encore Feature,
disabled by default. All account event listeners, reconciliation, modals,
menu entries, and UI components are gated so they remain inert until the
user enables Virtuosos in Settings → Encore.
Gate points:
- EncoreFeatureFlags.virtuosos flag + false default (types, settingsStore)
- AccountSelector self-gates via useSettingsStore (returns null when off)
- App.tsx: 6 account event useEffects, reconciliation, default assignment,
AccountSwitchModal, VirtuososModal, setVirtuososOpen prop
- SessionList: optional setVirtuososOpen prop, hamburger menu conditional
- useSessionListProps: optional setVirtuososOpen in deps interface
- SettingsModal: Virtuosos toggle card in Encore tab
- AccountSelector test mock updated for feature flag
Co-Authored-By: Claude Opus 4.6
---
.../components/AccountSelector.test.tsx | 5 +++
src/renderer/App.tsx | 41 +++++++++++--------
src/renderer/components/AccountSelector.tsx | 4 ++
src/renderer/components/SessionList.tsx | 38 +++++++++--------
src/renderer/components/SettingsModal.tsx | 36 ++++++++++++++++
.../hooks/props/useSessionListProps.ts | 2 +-
src/renderer/stores/settingsStore.ts | 1 +
src/renderer/types/index.ts | 1 +
8 files changed, 93 insertions(+), 35 deletions(-)
diff --git a/src/__tests__/renderer/components/AccountSelector.test.tsx b/src/__tests__/renderer/components/AccountSelector.test.tsx
index fa876c7cf..e3bde6e38 100644
--- a/src/__tests__/renderer/components/AccountSelector.test.tsx
+++ b/src/__tests__/renderer/components/AccountSelector.test.tsx
@@ -9,6 +9,11 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
import type { Theme } from '../../../shared/theme-types';
import type { AccountProfile } from '../../../shared/account-types';
+// Mock settingsStore to enable virtuosos feature flag
+vi.mock('../../../renderer/stores/settingsStore', () => ({
+ useSettingsStore: (selector: any) => selector({ encoreFeatures: { virtuosos: true } }),
+}));
+
// Mock useAccountUsage before importing the component
vi.mock('../../../renderer/hooks/useAccountUsage', () => ({
useAccountUsage: vi.fn().mockReturnValue({
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index f2e455ead..45094266c 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -136,6 +136,7 @@ import { useAgentListeners } from './hooks/agent/useAgentListeners';
// Import contexts
import { useLayerStack } from './contexts/LayerStackContext';
import { useNotificationStore, notifyToast } from './stores/notificationStore';
+import { useSettingsStore } from './stores/settingsStore';
import { useModalActions, useModalStore } from './stores/modalStore';
import { GitStatusProvider } from './contexts/GitStatusContext';
import { InputProvider, useInputContext } from './contexts/InputContext';
@@ -1243,7 +1244,7 @@ function MaestroConsoleInner() {
// Reconcile account assignments after session restore (ACCT-MUX-13)
// This validates accounts still exist and updates customEnvVars accordingly
- try {
+ if (useSettingsStore.getState().encoreFeatures.virtuosos) try {
const activeIds = restoredSessions.map(s => s.id);
const reconciliation = await window.maestro.accounts.reconcileSessions(activeIds);
if (reconciliation.success && reconciliation.corrections.length > 0) {
@@ -1606,6 +1607,7 @@ function MaestroConsoleInner() {
// Subscribe to account limit warning/reached events for toast notifications
useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
const unsubWarning = window.maestro.accounts.onLimitWarning((data) => {
notifyToast({
type: 'warning',
@@ -1628,10 +1630,11 @@ function MaestroConsoleInner() {
unsubWarning();
unsubReached();
};
- }, []);
+ }, [encoreFeatures.virtuosos]);
// Subscribe to account recovery events for auto-resume of paused Auto Runs
useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
const unsubRecovery = window.maestro.accounts.onRecoveryAvailable((data) => {
notifyToast({
type: 'success',
@@ -1673,10 +1676,11 @@ function MaestroConsoleInner() {
});
return () => unsubRecovery();
- }, []);
+ }, [encoreFeatures.virtuosos]);
// Subscribe to all-accounts-exhausted throttle events for Auto Run pause
useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
const unsubThrottled = window.maestro.accounts.onThrottled((data) => {
if (!data.noAlternatives) return; // Only handle the exhausted case
@@ -1703,10 +1707,11 @@ function MaestroConsoleInner() {
});
return () => unsubThrottled();
- }, []);
+ }, [encoreFeatures.virtuosos]);
// Subscribe to account assignment events (update session state when main process assigns an account)
useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
const unsubAssigned = window.maestro.accounts.onAssigned((data) => {
setSessions((prev) =>
prev.map(s => {
@@ -1716,10 +1721,11 @@ function MaestroConsoleInner() {
);
});
return () => unsubAssigned();
- }, []);
+ }, [encoreFeatures.virtuosos]);
// Subscribe to account switch events (respawn agent with new account after switch)
useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
const unsubRespawn = window.maestro.accounts.onSwitchRespawn(async (data) => {
const { sessionId: switchSessionId, toAccountId, toAccountName, configDir, lastPrompt, reason } = data;
@@ -1837,10 +1843,11 @@ function MaestroConsoleInner() {
unsubSwitchFailed();
unsubSwitchExecute();
};
- }, []);
+ }, [encoreFeatures.virtuosos]);
// Subscribe to account switch prompt events (user confirmation needed)
useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
const unsubSwitchPrompt = window.maestro.accounts.onSwitchPrompt((data: any) => {
setSwitchPromptData({
sessionId: data.sessionId,
@@ -1867,7 +1874,7 @@ function MaestroConsoleInner() {
unsubSwitchPrompt();
unsubSwitchCompleted();
};
- }, []);
+ }, [encoreFeatures.virtuosos]);
// Keyboard navigation state
// Note: selectedSidebarIndex/setSelectedSidebarIndex are destructured from useUIStore() above
@@ -5666,7 +5673,7 @@ You are taking over this conversation. Based on the context above, provide a bri
};
// Pre-assign account for Claude Code sessions if accounts are configured
- if (newSession.toolType === 'claude-code') {
+ if (encoreFeatures.virtuosos && newSession.toolType === 'claude-code') {
try {
const defaultAccount = await window.maestro.accounts.getDefault() as { id: string; name: string } | null;
if (defaultAccount) {
@@ -8577,7 +8584,7 @@ You are taking over this conversation. Based on the context above, provide a bri
setDuplicatingSessionId,
setGroupChatsExpanded,
setQuickActionOpen,
- setVirtuososOpen,
+ setVirtuososOpen: encoreFeatures.virtuosos ? setVirtuososOpen : undefined,
// Handlers
toggleGlobalLive,
@@ -9547,7 +9554,7 @@ You are taking over this conversation. Based on the context above, provide a bri
)}
{/* Account Switch Confirmation Modal */}
- {switchPromptData && (
+ {encoreFeatures.virtuosos && switchPromptData && (
setVirtuososOpen(false)}
- theme={theme}
- sessions={sessions}
- />
+ {encoreFeatures.virtuosos && (
+ setVirtuososOpen(false)}
+ theme={theme}
+ sessions={sessions}
+ />
+ )}
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
{sessions.length === 0 && !isMobileLandscape ? (
diff --git a/src/renderer/components/AccountSelector.tsx b/src/renderer/components/AccountSelector.tsx
index de4b863fd..6b6c88652 100644
--- a/src/renderer/components/AccountSelector.tsx
+++ b/src/renderer/components/AccountSelector.tsx
@@ -12,6 +12,7 @@ import { User, ChevronDown, Settings } from 'lucide-react';
import type { Theme } from '../types';
import type { AccountProfile } from '../../shared/account-types';
import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
+import { useSettingsStore } from '../stores/settingsStore';
export interface AccountSelectorProps {
theme: Theme;
@@ -46,6 +47,7 @@ export function AccountSelector({
onManageAccounts,
compact = false,
}: AccountSelectorProps) {
+ const virtuososEnabled = useSettingsStore(state => state.encoreFeatures.virtuosos);
const [accounts, setAccounts] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
@@ -103,6 +105,8 @@ export function AccountSelector({
[currentAccountId, onSwitchAccount]
);
+ if (!virtuososEnabled) return null;
+
return (
{/* Trigger button */}
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index 1679f3c31..c3250d1ef 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -460,7 +460,7 @@ interface HamburgerMenuContentProps {
setAboutModalOpen: (open: boolean) => void;
setMenuOpen: (open: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
- setVirtuososOpen: (open: boolean) => void;
+ setVirtuososOpen?: (open: boolean) => void;
}
function HamburgerMenuContent({
@@ -625,23 +625,25 @@ function HamburgerMenuContent({
{formatShortcutKeys(shortcuts.settings.keys)}
-
{
- setVirtuososOpen(true);
- setMenuOpen(false);
- }}
- className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
- >
-
-
-
- Virtuosos
-
-
- AI Provider Accounts
+ {setVirtuososOpen && (
+
{
+ setVirtuososOpen(true);
+ setMenuOpen(false);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
+ >
+
+
+
+ Virtuosos
+
+
+ AI Provider Accounts
+
-
-
+
+ )}
{
setLogViewerOpen(true);
@@ -1120,7 +1122,7 @@ interface SessionListProps {
setSymphonyModalOpen: (open: boolean) => void;
setDirectorNotesOpen?: (open: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
- setVirtuososOpen: (open: boolean) => void;
+ setVirtuososOpen?: (open: boolean) => void;
toggleGroup: (groupId: string) => void;
handleDragStart: (sessionId: string) => void;
handleDragOver: (e: React.DragEvent) => void;
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index 0f2a2c77b..4e6f758d4 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -35,6 +35,7 @@ import {
User,
ArrowDownToLine,
Clapperboard,
+ Users,
} from 'lucide-react';
import { useSettings } from '../hooks';
import type {
@@ -3317,6 +3318,41 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
);
})()}
+
+ {/* Virtuosos Feature Section */}
+
+
setEncoreFeatures({ ...encoreFeatures, virtuosos: !encoreFeatures.virtuosos })}
+ >
+
+
+
+
+ Virtuosos
+
+
+ Multi-account multiplexing for AI provider rate limits
+
+
+
+
+
+
)}
diff --git a/src/renderer/hooks/props/useSessionListProps.ts b/src/renderer/hooks/props/useSessionListProps.ts
index 8a6866588..30c34652e 100644
--- a/src/renderer/hooks/props/useSessionListProps.ts
+++ b/src/renderer/hooks/props/useSessionListProps.ts
@@ -100,7 +100,7 @@ export interface UseSessionListPropsDeps {
setDuplicatingSessionId: (id: string | null) => void;
setGroupChatsExpanded: (expanded: boolean) => void;
setQuickActionOpen: (open: boolean) => void;
- setVirtuososOpen: (open: boolean) => void;
+ setVirtuososOpen?: (open: boolean) => void;
// Handlers (should be memoized with useCallback)
toggleGlobalLive: () => void;
diff --git a/src/renderer/stores/settingsStore.ts b/src/renderer/stores/settingsStore.ts
index 9f4d4dbef..3abbb8ac1 100644
--- a/src/renderer/stores/settingsStore.ts
+++ b/src/renderer/stores/settingsStore.ts
@@ -118,6 +118,7 @@ export const DEFAULT_ONBOARDING_STATS: OnboardingStats = {
export const DEFAULT_ENCORE_FEATURES: EncoreFeatureFlags = {
directorNotes: false,
+ virtuosos: false,
};
export const DEFAULT_DIRECTOR_NOTES_SETTINGS: DirectorNotesSettings = {
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index e633eb972..10c903f4c 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -910,6 +910,7 @@ export interface LeaderboardSubmitResponse {
// Each key is a feature ID, value indicates whether it's enabled
export interface EncoreFeatureFlags {
directorNotes: boolean;
+ virtuosos: boolean;
}
// Director's Notes settings for synopsis generation
From d84f5bc972f06265bc0ac2893b90e49fbb79c8d6 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 02:39:12 -0500
Subject: [PATCH 35/59] MAESTRO: add session provenance fields and
ProviderSwitchConfig type
Add migration tracking fields (migratedFromSessionId, migratedToSessionId,
migratedAt, archivedByMigration, migrationGeneration) to the Session
interface for Virtuosos provider switching provenance chains.
Add ProviderSwitchConfig interface and DEFAULT_PROVIDER_SWITCH_CONFIG
to shared/account-types.ts for automated provider failover configuration.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/types/index.ts | 12 ++++++++++++
src/shared/account-types.ts | 27 +++++++++++++++++++++++++++
2 files changed, 39 insertions(+)
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index 10c903f4c..4225d4812 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -710,6 +710,18 @@ export interface Session {
accountId?: string;
/** Display name of the assigned account (for UI display without lookup) */
accountName?: string;
+
+ // Provider migration provenance (Virtuosos vertical swapping)
+ /** ID of the session this was migrated FROM (null if original) */
+ migratedFromSessionId?: string;
+ /** ID of the session this was migrated TO (set on source after switch) */
+ migratedToSessionId?: string;
+ /** Timestamp of the provider migration */
+ migratedAt?: number;
+ /** Whether this session was auto-archived after provider switch */
+ archivedByMigration?: boolean;
+ /** Migration generation counter (0 = original, increments with each switch) */
+ migrationGeneration?: number;
}
export interface AgentConfigOption {
diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts
index abb90a320..7da7d1d04 100644
--- a/src/shared/account-types.ts
+++ b/src/shared/account-types.ts
@@ -128,3 +128,30 @@ export const ACCOUNT_SWITCH_DEFAULTS: AccountSwitchConfig = {
/** Default token window: 5 hours in milliseconds */
export const DEFAULT_TOKEN_WINDOW_MS = 5 * 60 * 60 * 1000;
+
+import type { ToolType } from './types';
+
+/**
+ * Configuration for automated provider failover (Virtuosos vertical swapping).
+ * Stored in settings alongside account switch config.
+ */
+export interface ProviderSwitchConfig {
+ /** Whether auto-provider-failover is enabled */
+ enabled: boolean;
+ /** Whether to prompt user before auto-switching */
+ promptBeforeSwitch: boolean;
+ /** Consecutive error count threshold before suggesting failover */
+ errorThreshold: number;
+ /** Time window for error counting (ms) */
+ errorWindowMs: number;
+ /** Ordered list of fallback providers (tried in order) */
+ fallbackProviders: ToolType[];
+}
+
+export const DEFAULT_PROVIDER_SWITCH_CONFIG: ProviderSwitchConfig = {
+ enabled: false,
+ promptBeforeSwitch: true,
+ errorThreshold: 3,
+ errorWindowMs: 5 * 60 * 1000, // 5 minutes
+ fallbackProviders: [],
+};
From 8c8d5751d627b8186402f431274f636ae0803420 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 02:46:25 -0500
Subject: [PATCH 36/59] MAESTRO: add useProviderSwitch hook and extend
createMergedSession for identity carry-over
Extend CreateMergedSessionOptions with identity (nudgeMessage, bookmarked,
sessionSshRemoteConfig, autoRunFolderPath) and provenance (migratedFromSessionId,
migratedAt, migrationGeneration) fields. Apply them in createMergedSession so
sessions created via provider switching are born complete.
Create useProviderSwitch orchestrator hook that reuses the existing context
transfer pipeline (extractTabContext, contextGroomingService, createMergedSession)
but differs from useSendToAgent: preserves original session name, carries full
identity, pre-loads context in tab logs, and sets provenance fields.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/hooks/agent/index.ts | 8 +
src/renderer/hooks/agent/useProviderSwitch.ts | 404 ++++++++++++++++++
src/renderer/utils/tabHelpers.ts | 32 +-
3 files changed, 442 insertions(+), 2 deletions(-)
create mode 100644 src/renderer/hooks/agent/useProviderSwitch.ts
diff --git a/src/renderer/hooks/agent/index.ts b/src/renderer/hooks/agent/index.ts
index 3ac7fb563..961082b36 100644
--- a/src/renderer/hooks/agent/index.ts
+++ b/src/renderer/hooks/agent/index.ts
@@ -84,6 +84,14 @@ export type {
UseSendToAgentWithSessionsResult,
} from './useSendToAgent';
+// Provider switching (Virtuosos vertical swapping)
+export { useProviderSwitch } from './useProviderSwitch';
+export type {
+ ProviderSwitchRequest,
+ ProviderSwitchResult,
+ UseProviderSwitchResult,
+} from './useProviderSwitch';
+
// Summarize and continue (context compaction)
export { useSummarizeAndContinue } from './useSummarizeAndContinue';
export type {
diff --git a/src/renderer/hooks/agent/useProviderSwitch.ts b/src/renderer/hooks/agent/useProviderSwitch.ts
new file mode 100644
index 000000000..c0c4bec84
--- /dev/null
+++ b/src/renderer/hooks/agent/useProviderSwitch.ts
@@ -0,0 +1,404 @@
+/**
+ * useProviderSwitch Hook
+ *
+ * Orchestrates the provider switch workflow for Virtuosos vertical swapping.
+ * Creates a new session with a different agent type while preserving:
+ * - Session identity (name, cwd, group, bookmarks, SSH config, nudge, auto-run path)
+ * - Conversation context (optionally groomed for the target provider)
+ * - Provenance chain (migratedFromSessionId, migratedAt, migrationGeneration)
+ *
+ * Key differences from useSendToAgent:
+ * - Session name is preserved (not "Source → Target")
+ * - Full identity carry-over (not just groupId)
+ * - Context is pre-loaded in tab logs (not auto-sent as first message)
+ * - Source session can be archived with back-link
+ * - Provenance fields are set on the new session
+ *
+ * State lives in operationStore (Zustand); this hook owns orchestration only.
+ */
+
+import { useCallback, useRef } from 'react';
+import type { Session, LogEntry, ToolType } from '../../types';
+import type { GroomingProgress, MergeRequest } from '../../types/contextMerge';
+import type { TransferState, TransferLastRequest } from '../../stores/operationStore';
+import {
+ ContextGroomingService,
+ contextGroomingService,
+ buildContextTransferPrompt,
+ getAgentDisplayName,
+} from '../../services/contextGroomer';
+import { extractTabContext } from '../../utils/contextExtractor';
+import { createMergedSession } from '../../utils/tabHelpers';
+import { classifyTransferError } from '../../components/TransferErrorModal';
+import { useOperationStore } from '../../stores/operationStore';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ProviderSwitchRequest {
+ /** Source session to switch from */
+ sourceSession: Session;
+ /** Tab ID within source session (active tab) */
+ sourceTabId: string;
+ /** Target provider to switch to */
+ targetProvider: ToolType;
+ /** Whether to groom context for target provider */
+ groomContext: boolean;
+ /** Whether to auto-archive source session after switch */
+ archiveSource: boolean;
+}
+
+export interface ProviderSwitchResult {
+ success: boolean;
+ /** The complete new session object (caller adds to state) */
+ newSession?: Session;
+ /** New session ID (if successful) */
+ newSessionId?: string;
+ /** New tab ID within new session */
+ newTabId?: string;
+ /** Tokens saved via grooming */
+ tokensSaved?: number;
+ /** Error message (if failed) */
+ error?: string;
+}
+
+export interface UseProviderSwitchResult {
+ switchProvider: (request: ProviderSwitchRequest) => Promise;
+ transferState: TransferState;
+ progress: GroomingProgress | null;
+ error: string | null;
+ cancelSwitch: () => void;
+ reset: () => void;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const INITIAL_PROGRESS: GroomingProgress = {
+ stage: 'collecting',
+ progress: 0,
+ message: 'Preparing provider switch...',
+};
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+/**
+ * Hook for managing provider switch operations (Virtuosos vertical swapping).
+ *
+ * @example
+ * const { switchProvider, transferState, progress, cancelSwitch } = useProviderSwitch();
+ *
+ * const result = await switchProvider({
+ * sourceSession,
+ * sourceTabId: activeTabId,
+ * targetProvider: 'codex',
+ * groomContext: true,
+ * archiveSource: true,
+ * });
+ *
+ * if (result.success && result.newSession) {
+ * setSessions(prev => [...prev, result.newSession!]);
+ * setActiveSessionId(result.newSessionId);
+ * }
+ */
+export function useProviderSwitch(): UseProviderSwitchResult {
+ // State from operationStore (reuses transfer state)
+ const transferState = useOperationStore((s) => s.transferState);
+ const progress = useOperationStore((s) => s.transferProgress);
+ const error = useOperationStore((s) => s.transferError);
+
+ // Refs for cancellation
+ const cancelledRef = useRef(false);
+ const groomingServiceRef = useRef(contextGroomingService);
+ const switchStartTimeRef = useRef(0);
+
+ /**
+ * Reset hook state to idle.
+ */
+ const reset = useCallback(() => {
+ useOperationStore.getState().resetTransferState();
+ cancelledRef.current = false;
+ }, []);
+
+ /**
+ * Cancel an in-progress switch operation.
+ */
+ const cancelSwitch = useCallback(() => {
+ cancelledRef.current = true;
+ groomingServiceRef.current.cancelGrooming();
+
+ useOperationStore.getState().setTransferState({
+ state: 'idle',
+ progress: null,
+ error: 'Provider switch cancelled by user',
+ transferError: null,
+ });
+ }, []);
+
+ /**
+ * Execute the provider switch workflow.
+ */
+ const switchProvider = useCallback(
+ async (request: ProviderSwitchRequest): Promise => {
+ const { sourceSession, sourceTabId, targetProvider, groomContext } = request;
+
+ const store = useOperationStore.getState();
+
+ // Prevent concurrent operations
+ if (store.globalTransferInProgress) {
+ return {
+ success: false,
+ error: 'A transfer operation is already in progress. Please wait for it to complete.',
+ };
+ }
+
+ // Set global flag
+ store.setGlobalTransferInProgress(true);
+
+ // Reset and start
+ cancelledRef.current = false;
+ switchStartTimeRef.current = Date.now();
+
+ const minimalRequest: TransferLastRequest = {
+ sourceSessionId: sourceSession.id,
+ sourceTabId,
+ targetAgent: targetProvider,
+ skipGrooming: !groomContext,
+ };
+
+ store.setTransferState({
+ state: 'grooming',
+ progress: INITIAL_PROGRESS,
+ error: null,
+ transferError: null,
+ lastRequest: minimalRequest,
+ });
+
+ try {
+ // Step 1: Validate inputs
+ const sourceTab = sourceSession.aiTabs.find((t) => t.id === sourceTabId);
+ if (!sourceTab) {
+ throw new Error('Source tab not found');
+ }
+
+ if (sourceTab.logs.length === 0) {
+ throw new Error(
+ 'Cannot switch provider with empty context - source tab has no conversation history'
+ );
+ }
+
+ // Verify target agent is available
+ try {
+ const agentStatus = await window.maestro.agents.get(targetProvider);
+ if (!agentStatus?.available) {
+ throw new Error(
+ `${getAgentDisplayName(targetProvider)} is not available. Please install and configure it first.`
+ );
+ }
+ } catch (agentCheckError) {
+ // If we can't check, log warning but continue
+ console.warn('Could not verify agent availability:', agentCheckError);
+ }
+
+ if (cancelledRef.current) {
+ return { success: false, error: 'Provider switch cancelled' };
+ }
+
+ // Step 2: Extract context from source tab
+ useOperationStore.getState().setTransferState({
+ progress: {
+ stage: 'collecting',
+ progress: 10,
+ message: 'Extracting source context...',
+ },
+ });
+
+ const sessionDisplayName =
+ sourceSession.name ||
+ sourceSession.projectRoot.split('/').pop() ||
+ 'Unnamed Session';
+
+ const sourceContext = extractTabContext(sourceTab, sessionDisplayName, sourceSession);
+
+ if (cancelledRef.current) {
+ return { success: false, error: 'Provider switch cancelled' };
+ }
+
+ // Step 3: Groom context if enabled
+ let contextLogs: LogEntry[];
+ let tokensSaved = 0;
+
+ if (groomContext) {
+ useOperationStore.getState().setTransferState({
+ progress: {
+ stage: 'grooming',
+ progress: 20,
+ message: `Grooming context for ${getAgentDisplayName(targetProvider)}...`,
+ },
+ });
+
+ const transferPrompt = buildContextTransferPrompt(
+ sourceSession.toolType,
+ targetProvider
+ );
+
+ const groomingRequest: MergeRequest = {
+ sources: [sourceContext],
+ targetAgent: targetProvider,
+ targetProjectRoot: sourceSession.projectRoot,
+ groomingPrompt: transferPrompt,
+ };
+
+ const groomingResult = await groomingServiceRef.current.groomContexts(
+ groomingRequest,
+ (groomProgress) => {
+ useOperationStore.getState().setTransferState({
+ progress: {
+ ...groomProgress,
+ message:
+ groomProgress.stage === 'grooming'
+ ? `Grooming for ${getAgentDisplayName(targetProvider)}: ${groomProgress.message}`
+ : groomProgress.message,
+ },
+ });
+ }
+ );
+
+ if (cancelledRef.current) {
+ return { success: false, error: 'Provider switch cancelled' };
+ }
+
+ if (!groomingResult.success) {
+ throw new Error(groomingResult.error || 'Context grooming failed');
+ }
+
+ contextLogs = groomingResult.groomedLogs;
+ tokensSaved = groomingResult.tokensSaved;
+ } else {
+ useOperationStore.getState().setTransferState({
+ progress: {
+ stage: 'grooming',
+ progress: 50,
+ message: 'Preparing context without grooming...',
+ },
+ });
+
+ contextLogs = [...sourceContext.logs];
+ }
+
+ if (cancelledRef.current) {
+ return { success: false, error: 'Provider switch cancelled' };
+ }
+
+ // Step 4: Create new session via extended createMergedSession
+ useOperationStore.getState().setTransferState({
+ state: 'creating',
+ progress: {
+ stage: 'creating',
+ progress: 80,
+ message: `Creating ${getAgentDisplayName(targetProvider)} session...`,
+ },
+ });
+
+ const { session: newSession, tabId: newTabId } = createMergedSession({
+ name: sourceSession.name,
+ projectRoot: sourceSession.projectRoot,
+ toolType: targetProvider,
+ mergedLogs: contextLogs,
+ groupId: sourceSession.groupId,
+ // Identity carry-over
+ nudgeMessage: sourceSession.nudgeMessage,
+ bookmarked: sourceSession.bookmarked,
+ sessionSshRemoteConfig: sourceSession.sessionSshRemoteConfig,
+ autoRunFolderPath: sourceSession.autoRunFolderPath,
+ // Provenance
+ migratedFromSessionId: sourceSession.id,
+ migratedAt: Date.now(),
+ migrationGeneration: (sourceSession.migrationGeneration || 0) + 1,
+ });
+
+ // Step 5: Add transfer notice to new session tab
+ const sourceName = getAgentDisplayName(sourceSession.toolType);
+ const targetName = getAgentDisplayName(targetProvider);
+ const groomNote = groomContext
+ ? 'Context groomed and optimized.'
+ : 'Context preserved as-is.';
+
+ const transferNotice: LogEntry = {
+ id: `provider-switch-notice-${Date.now()}`,
+ timestamp: Date.now(),
+ source: 'system',
+ text: `Provider switched from ${sourceName} to ${targetName}. ${groomNote}`,
+ };
+
+ const activeTab = newSession.aiTabs.find((t) => t.id === newTabId);
+ if (activeTab) {
+ activeTab.logs = [transferNotice, ...activeTab.logs];
+ }
+
+ // Step 6: Complete
+ useOperationStore.getState().setTransferState({
+ state: 'complete',
+ progress: {
+ stage: 'complete',
+ progress: 100,
+ message: `Provider switch complete!${tokensSaved > 0 ? ` Saved ~${tokensSaved} tokens` : ''}`,
+ },
+ });
+
+ return {
+ success: true,
+ newSession,
+ newSessionId: newSession.id,
+ newTabId,
+ tokensSaved,
+ };
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'Unknown error during provider switch';
+ const elapsedTimeMs = Date.now() - switchStartTimeRef.current;
+
+ const classifiedError = classifyTransferError(errorMessage, {
+ sourceAgent: sourceSession.toolType,
+ targetAgent: targetProvider,
+ wasGrooming: groomContext,
+ elapsedTimeMs,
+ });
+
+ useOperationStore.getState().setTransferState({
+ state: 'error',
+ error: errorMessage,
+ transferError: classifiedError,
+ progress: {
+ stage: 'complete',
+ progress: 100,
+ message: `Provider switch failed: ${errorMessage}`,
+ },
+ });
+
+ return {
+ success: false,
+ error: errorMessage,
+ };
+ } finally {
+ useOperationStore.getState().setGlobalTransferInProgress(false);
+ }
+ },
+ []
+ );
+
+ return {
+ switchProvider,
+ transferState,
+ progress,
+ error,
+ cancelSwitch,
+ reset,
+ };
+}
+
+export default useProviderSwitch;
diff --git a/src/renderer/utils/tabHelpers.ts b/src/renderer/utils/tabHelpers.ts
index 5b8bd3669..75602e39f 100644
--- a/src/renderer/utils/tabHelpers.ts
+++ b/src/renderer/utils/tabHelpers.ts
@@ -1459,6 +1459,24 @@ export interface CreateMergedSessionOptions {
saveToHistory?: boolean;
/** Thinking display mode: 'off' | 'on' (temporary) | 'sticky' (persistent) */
showThinking?: ThinkingMode;
+
+ // --- Identity carry-over (provider switching) ---
+ /** Nudge message from source session */
+ nudgeMessage?: string;
+ /** Whether the session is bookmarked */
+ bookmarked?: boolean;
+ /** SSH remote configuration from source session */
+ sessionSshRemoteConfig?: Session['sessionSshRemoteConfig'];
+ /** Auto Run folder path override (defaults to standard path if not provided) */
+ autoRunFolderPath?: string;
+
+ // --- Provenance (provider switching) ---
+ /** ID of the session this was migrated from */
+ migratedFromSessionId?: string;
+ /** Timestamp of the migration */
+ migratedAt?: number;
+ /** Migration generation counter (0 = original, increments) */
+ migrationGeneration?: number;
}
/**
@@ -1572,8 +1590,18 @@ export function createMergedSession(
activeFileTabId: null,
unifiedTabOrder: [{ type: 'ai' as const, id: tabId }],
unifiedClosedTabHistory: [],
- // Default Auto Run folder path (user can change later)
- autoRunFolderPath: getAutoRunFolderPath(projectRoot),
+ // Default Auto Run folder path (user can change later, provider switch can override)
+ autoRunFolderPath: options.autoRunFolderPath ?? getAutoRunFolderPath(projectRoot),
+
+ // Identity carry-over (provider switching)
+ ...(options.nudgeMessage != null && { nudgeMessage: options.nudgeMessage }),
+ ...(options.bookmarked != null && { bookmarked: options.bookmarked }),
+ ...(options.sessionSshRemoteConfig != null && { sessionSshRemoteConfig: options.sessionSshRemoteConfig }),
+
+ // Provenance (provider switching)
+ ...(options.migratedFromSessionId != null && { migratedFromSessionId: options.migratedFromSessionId }),
+ ...(options.migratedAt != null && { migratedAt: options.migratedAt }),
+ ...(options.migrationGeneration != null && { migrationGeneration: options.migrationGeneration }),
};
return { session, tabId };
From 5d92c362576b08b8aebd23a4c63d7d7a057ccf0d Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 02:52:25 -0500
Subject: [PATCH 37/59] MAESTRO: add SwitchProviderModal component and
PROVIDER_SWITCH priority
Add the confirmation modal for Virtuosos provider switching (VSWITCH-03).
Includes agent detection, radio-button provider selection with availability
badges, groom context and archive source checkboxes, token estimation,
and keyboard navigation. Uses Modal base component with layer stack
registration for Escape handling. PROVIDER_SWITCH priority placed at 1003,
near ACCOUNT_SWITCH at 1005.
Co-Authored-By: Claude Opus 4.6
---
.../components/SwitchProviderModal.tsx | 432 ++++++++++++++++++
src/renderer/constants/modalPriorities.ts | 3 +
2 files changed, 435 insertions(+)
create mode 100644 src/renderer/components/SwitchProviderModal.tsx
diff --git a/src/renderer/components/SwitchProviderModal.tsx b/src/renderer/components/SwitchProviderModal.tsx
new file mode 100644
index 000000000..af10cb14a
--- /dev/null
+++ b/src/renderer/components/SwitchProviderModal.tsx
@@ -0,0 +1,432 @@
+/**
+ * SwitchProviderModal - Confirmation modal for Virtuosos provider switching
+ *
+ * Lets users select a target provider and configure switch options (groom context,
+ * archive source) before initiating a provider switch. Shows current provider,
+ * available targets with availability status, and estimated token count.
+ *
+ * Pattern references:
+ * - AccountSwitchModal for themed modal structure
+ * - SendToAgentModal for agent selection + keyboard navigation
+ * - Modal base component for consistent chrome + layer stack
+ */
+
+import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
+import { ArrowDown, Shuffle } from 'lucide-react';
+import type { Theme, Session, ToolType, AgentConfig } from '../types';
+import { Modal } from './ui/Modal';
+import { MODAL_PRIORITIES } from '../constants/modalPriorities';
+import { getAgentIcon } from '../constants/agentIcons';
+import { getAgentDisplayName } from '../services/contextGroomer';
+import { formatTokensCompact } from '../utils/formatters';
+
+export interface SwitchProviderModalProps {
+ theme: Theme;
+ isOpen: boolean;
+ onClose: () => void;
+ /** The session being switched */
+ sourceSession: Session;
+ /** Active tab ID for context extraction */
+ sourceTabId: string;
+ /** Callback when user confirms the switch */
+ onConfirmSwitch: (request: {
+ targetProvider: ToolType;
+ groomContext: boolean;
+ archiveSource: boolean;
+ }) => void;
+}
+
+interface ProviderOption {
+ id: ToolType;
+ name: string;
+ available: boolean;
+}
+
+/**
+ * Estimate token count from tab log entries.
+ * Uses ~4 characters per token heuristic (same as SendToAgentModal).
+ */
+function estimateTokensFromLogs(logs: { text: string }[]): number {
+ const totalChars = logs.reduce((sum, log) => sum + (log.text?.length || 0), 0);
+ return Math.round(totalChars / 4);
+}
+
+export function SwitchProviderModal({
+ theme,
+ isOpen,
+ onClose,
+ sourceSession,
+ sourceTabId,
+ onConfirmSwitch,
+}: SwitchProviderModalProps) {
+ // Provider selection
+ const [selectedProvider, setSelectedProvider] = useState(null);
+ const [highlightedIndex, setHighlightedIndex] = useState(0);
+
+ // Options
+ const [groomContext, setGroomContext] = useState(true);
+ const [archiveSource, setArchiveSource] = useState(true);
+
+ // Detected agents
+ const [providers, setProviders] = useState([]);
+
+ // Ref for scrolling highlighted item into view
+ const highlightedRef = useRef(null);
+
+ // Detect available providers when modal opens
+ useEffect(() => {
+ if (!isOpen) return;
+
+ let mounted = true;
+
+ (async () => {
+ try {
+ const agents: AgentConfig[] = await window.maestro.agents.detect();
+
+ if (!mounted) return;
+
+ const options: ProviderOption[] = agents
+ // Filter out: current provider, terminal, hidden agents
+ .filter((a) => {
+ if (a.id === sourceSession.toolType) return false;
+ if (a.id === 'terminal') return false;
+ if (a.hidden) return false;
+ return true;
+ })
+ .map((a) => ({
+ id: a.id as ToolType,
+ name: a.name || getAgentDisplayName(a.id as ToolType),
+ available: a.available,
+ }))
+ // Sort: available first, then alphabetically
+ .sort((a, b) => {
+ if (a.available !== b.available) return a.available ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ setProviders(options);
+ } catch (err) {
+ console.error('Failed to detect agents for provider switch:', err);
+ }
+ })();
+
+ return () => {
+ mounted = false;
+ };
+ }, [isOpen, sourceSession.toolType]);
+
+ // Reset state when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ setSelectedProvider(null);
+ setHighlightedIndex(0);
+ setGroomContext(true);
+ setArchiveSource(true);
+ }
+ }, [isOpen]);
+
+ // Scroll highlighted item into view
+ useEffect(() => {
+ highlightedRef.current?.scrollIntoView({ block: 'nearest' });
+ }, [highlightedIndex]);
+
+ // Token estimate from active tab
+ const tokenEstimate = useMemo(() => {
+ const tab = sourceSession.aiTabs.find((t) => t.id === sourceTabId);
+ if (!tab) return 0;
+ return estimateTokensFromLogs(tab.logs);
+ }, [sourceSession, sourceTabId]);
+
+ // Available (selectable) providers for keyboard nav
+ const selectableProviders = useMemo(
+ () => providers.filter((p) => p.available),
+ [providers]
+ );
+
+ // Handle confirm
+ const handleConfirm = useCallback(() => {
+ if (!selectedProvider) return;
+ onConfirmSwitch({
+ targetProvider: selectedProvider,
+ groomContext,
+ archiveSource,
+ });
+ }, [selectedProvider, groomContext, archiveSource, onConfirmSwitch]);
+
+ // Keyboard navigation handler
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev + 1 < selectableProviders.length ? prev + 1 : prev
+ );
+ return;
+ }
+
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setHighlightedIndex((prev) => (prev - 1 >= 0 ? prev - 1 : prev));
+ return;
+ }
+
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ if (selectedProvider) {
+ handleConfirm();
+ } else if (selectableProviders[highlightedIndex]) {
+ setSelectedProvider(selectableProviders[highlightedIndex].id);
+ }
+ return;
+ }
+
+ if (e.key === ' ') {
+ e.preventDefault();
+ if (selectableProviders[highlightedIndex]) {
+ setSelectedProvider(selectableProviders[highlightedIndex].id);
+ }
+ return;
+ }
+ },
+ [selectableProviders, highlightedIndex, selectedProvider, handleConfirm]
+ );
+
+ if (!isOpen) return null;
+
+ const currentProviderName = getAgentDisplayName(sourceSession.toolType);
+ const currentProviderIcon = getAgentIcon(sourceSession.toolType);
+
+ return (
+ }
+ width={480}
+ closeOnBackdropClick
+ footer={
+
+
+
+ Cancel
+
+
+ Switch Provider
+
+
+ }
+ >
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
+ {/* Current Provider */}
+
+
+ Current Provider
+
+
+
{currentProviderIcon}
+
+
+ {currentProviderName}
+
+
+
+ active
+
+
+
+
+ {/* Arrow */}
+
+
+ {/* Target Provider Selection */}
+
+
+ Select target provider:
+
+
+ {providers.length === 0 ? (
+
+ No other providers detected
+
+ ) : (
+ providers.map((provider) => {
+ const isSelected = selectedProvider === provider.id;
+ const isAvailable = provider.available;
+ const selectableIndex = selectableProviders.findIndex((p) => p.id === provider.id);
+ const isHighlighted = isAvailable && selectableIndex === highlightedIndex;
+
+ return (
+
{
+ setSelectedProvider(provider.id);
+ if (selectableIndex >= 0) setHighlightedIndex(selectableIndex);
+ }}
+ className="w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed border-b last:border-b-0"
+ style={{
+ borderColor: theme.colors.border,
+ backgroundColor: isSelected
+ ? `${theme.colors.accent}15`
+ : isHighlighted
+ ? `${theme.colors.accent}08`
+ : 'transparent',
+ opacity: isAvailable ? 1 : 0.5,
+ }}
+ >
+ {/* Radio indicator */}
+
+ {isSelected && (
+
+ )}
+
+
+ {/* Agent icon */}
+ {getAgentIcon(provider.id)}
+
+ {/* Name */}
+
+ {provider.name}
+
+
+ {/* Availability badge */}
+
+
+ {isAvailable ? 'available' : 'Not Installed'}
+
+
+ );
+ })
+ )}
+
+
+
+ {/* Options */}
+
+
+ Options
+
+
+
+ setGroomContext(e.target.checked)}
+ className="mt-0.5 rounded"
+ />
+
+
+ Groom context for target provider
+
+
+ Remove agent-specific artifacts and adapt conversation for the target provider
+
+
+
+
+ setArchiveSource(e.target.checked)}
+ className="mt-0.5 rounded"
+ />
+
+
+ Archive source session
+
+
+ Dim the original session in the sidebar
+
+
+
+
+
+
+ {/* Token estimate */}
+
+ Context size: ~{formatTokensCompact(tokenEstimate)} tokens
+
+
+
+ );
+}
+
+export default SwitchProviderModal;
diff --git a/src/renderer/constants/modalPriorities.ts b/src/renderer/constants/modalPriorities.ts
index 3acc9626f..83fab903d 100644
--- a/src/renderer/constants/modalPriorities.ts
+++ b/src/renderer/constants/modalPriorities.ts
@@ -32,6 +32,9 @@ export const MODAL_PRIORITIES = {
/** Account switch confirmation modal - triggered by throttle/limit events */
ACCOUNT_SWITCH: 1005,
+ /** Provider switch modal - Virtuosos vertical swapping between agent types */
+ PROVIDER_SWITCH: 1003,
+
/** Confirmation dialogs - highest priority, always on top */
CONFIRM: 1000,
From 0d4002b8a192be0cd407d78ce7ce92737955e905 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 02:58:49 -0500
Subject: [PATCH 38/59] MAESTRO: add Switch Provider UI entry points to context
menu and Edit Agent modal
- Add "Switch Provider..." menu item to SessionContextMenu (after Edit Agent)
- Thread onSwitchProvider callback through SessionList and useSessionListProps
- Add "Switch..." button to Edit Agent modal's provider section
- Thread onSwitchProviderFromEdit through AppSessionModals and AppMasterModals
- All entry points gated behind optional callbacks (hidden when Virtuosos disabled)
- Actual SwitchProviderModal wiring deferred to VSWITCH-05
Co-Authored-By: Claude Opus 4.6
---
src/renderer/components/AppModals.tsx | 6 +++
src/renderer/components/NewInstanceModal.tsx | 39 +++++++++++++------
src/renderer/components/SessionList.tsx | 23 +++++++++++
.../hooks/props/useSessionListProps.ts | 5 +++
4 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx
index ffce88a87..107e3cc34 100644
--- a/src/renderer/components/AppModals.tsx
+++ b/src/renderer/components/AppModals.tsx
@@ -417,6 +417,7 @@ export interface AppSessionModalsProps {
customContextWindow?: number
) => void;
editAgentSession: Session | null;
+ onSwitchProviderFromEdit?: () => void; // Opens SwitchProviderModal (Virtuosos)
// RenameSessionModal
renameSessionModalOpen: boolean;
@@ -460,6 +461,7 @@ export function AppSessionModals({
onCloseEditAgentModal,
onSaveEditAgent,
editAgentSession,
+ onSwitchProviderFromEdit,
// RenameSessionModal
renameSessionModalOpen,
renameSessionValue,
@@ -495,6 +497,7 @@ export function AppSessionModals({
theme={theme}
session={editAgentSession}
existingSessions={existingSessions}
+ onSwitchProvider={onSwitchProviderFromEdit}
/>
{/* --- RENAME SESSION MODAL --- */}
@@ -1836,6 +1839,7 @@ export interface AppModalsProps {
customContextWindow?: number
) => void;
editAgentSession: Session | null;
+ onSwitchProviderFromEdit?: () => void; // Opens SwitchProviderModal (Virtuosos)
renameSessionModalOpen: boolean;
renameSessionValue: string;
setRenameSessionValue: (value: string) => void;
@@ -2174,6 +2178,7 @@ export function AppModals(props: AppModalsProps) {
onCloseEditAgentModal,
onSaveEditAgent,
editAgentSession,
+ onSwitchProviderFromEdit,
renameSessionModalOpen,
renameSessionValue,
setRenameSessionValue,
@@ -2464,6 +2469,7 @@ export function AppModals(props: AppModalsProps) {
onCloseEditAgentModal={onCloseEditAgentModal}
onSaveEditAgent={onSaveEditAgent}
editAgentSession={editAgentSession}
+ onSwitchProviderFromEdit={onSwitchProviderFromEdit}
renameSessionModalOpen={renameSessionModalOpen}
renameSessionValue={renameSessionValue}
setRenameSessionValue={setRenameSessionValue}
diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx
index 9a50c8b9a..3de98d743 100644
--- a/src/renderer/components/NewInstanceModal.tsx
+++ b/src/renderer/components/NewInstanceModal.tsx
@@ -73,6 +73,7 @@ interface EditAgentModalProps {
theme: any;
session: Session | null;
existingSessions: Session[];
+ onSwitchProvider?: () => void; // Opens SwitchProviderModal (Virtuosos)
}
// Supported agents that are fully implemented
@@ -1200,6 +1201,7 @@ export function EditAgentModal({
theme,
session,
existingSessions,
+ onSwitchProvider,
}: EditAgentModalProps) {
const [instanceName, setInstanceName] = useState('');
const [nudgeMessage, setNudgeMessage] = useState('');
@@ -1588,7 +1590,7 @@ export function EditAgentModal({
heightClass="p-2"
/>
- {/* Agent Provider (read-only) */}
+ {/* Agent Provider (read-only, with optional switch button) */}
Agent Provider
-
- {agentName}
+
+
+ {agentName}
+
+ {onSwitchProvider && (
+
{
+ onSwitchProvider();
+ }}
+ className="px-3 py-2 rounded border text-xs transition-colors hover:bg-white/5"
+ style={{ borderColor: theme.colors.border, color: theme.colors.accent }}
+ >
+ Switch...
+
+ )}
- Provider cannot be changed after creation.
+ {onSwitchProvider
+ ? 'Switch to a different provider. Context will be transferred.'
+ : 'Provider cannot be changed after creation.'}
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index c3250d1ef..f1536fbde 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -38,6 +38,7 @@ import {
Command,
User,
Users,
+ ArrowRightLeft,
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import type {
@@ -84,6 +85,7 @@ interface SessionContextMenuProps {
onConfigureWorktrees?: () => void; // Opens full worktree config modal
onDeleteWorktree?: () => void; // For worktree child sessions to delete
onCreateGroup?: () => void; // Creates a new group from the Move to Group submenu
+ onSwitchProvider?: () => void; // Opens SwitchProviderModal (Virtuosos)
}
function SessionContextMenu({
@@ -105,6 +107,7 @@ function SessionContextMenu({
onConfigureWorktrees,
onDeleteWorktree,
onCreateGroup,
+ onSwitchProvider,
}: SessionContextMenuProps) {
const menuRef = useRef
(null);
const moveToGroupRef = useRef(null);
@@ -212,6 +215,21 @@ function SessionContextMenu({
Edit Agent...
+ {/* Switch Provider (Virtuosos vertical swapping) */}
+ {onSwitchProvider && session.toolType !== 'terminal' && (
+ {
+ onSwitchProvider();
+ onDismiss();
+ }}
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-white/5 transition-colors flex items-center gap-2"
+ style={{ color: theme.colors.textMain }}
+ >
+
+ Switch Provider...
+
+ )}
+
{/* Account info - non-clickable info item */}
{session.accountId && (
void;
onDeleteWorktreeGroup?: (groupId: string) => void;
+ // Provider switching (Virtuosos)
+ onSwitchProvider?: (sessionId: string) => void;
+
// Rename modal handlers (for context menu rename)
setRenameInstanceModalOpen: (open: boolean) => void;
setRenameInstanceValue: (value: string) => void;
@@ -1265,6 +1286,7 @@ function SessionListInner(props: SessionListProps) {
addNewSession,
onDeleteSession,
onDeleteWorktreeGroup,
+ onSwitchProvider,
setRenameInstanceModalOpen,
setRenameInstanceValue,
setRenameInstanceSessionId,
@@ -3107,6 +3129,7 @@ function SessionListInner(props: SessionListProps) {
? () => onCreateGroupAndMove(contextMenuSession.id)
: createNewGroup
}
+ onSwitchProvider={onSwitchProvider ? () => onSwitchProvider(contextMenuSession.id) : undefined}
/>
)}
diff --git a/src/renderer/hooks/props/useSessionListProps.ts b/src/renderer/hooks/props/useSessionListProps.ts
index 30c34652e..cbdc0ee31 100644
--- a/src/renderer/hooks/props/useSessionListProps.ts
+++ b/src/renderer/hooks/props/useSessionListProps.ts
@@ -126,6 +126,7 @@ export interface UseSessionListPropsDeps {
handleOpenWorktreeConfigSession: (session: Session) => void;
handleDeleteWorktreeSession: (session: Session) => void;
handleToggleWorktreeExpanded: (sessionId: string) => void;
+ handleSwitchProvider?: (sessionId: string) => void;
openWizardModal: () => void;
handleStartTour: () => void;
@@ -242,6 +243,9 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
onOpenWorktreeConfig: deps.handleOpenWorktreeConfigSession,
onDeleteWorktree: deps.handleDeleteWorktreeSession,
+ // Provider switching (Virtuosos)
+ onSwitchProvider: deps.handleSwitchProvider,
+
// Auto mode
activeBatchSessionIds: deps.activeBatchSessionIds,
@@ -363,6 +367,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
deps.handleOpenWorktreeConfigSession,
deps.handleDeleteWorktreeSession,
deps.handleToggleWorktreeExpanded,
+ deps.handleSwitchProvider,
deps.openWizardModal,
deps.handleStartTour,
deps.handleOpenGroupChat,
From 8ac92596c320a487b301bffc1813390e33ab157a Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 03:07:53 -0500
Subject: [PATCH 39/59] MAESTRO: wire SwitchProviderModal state, hooks, and
callbacks in App.tsx
Add provider switching UI integration:
- Import SwitchProviderModal and useProviderSwitch hook
- Add switchProviderSession state for tracking which session is being switched
- Create handleSwitchProvider and handleConfirmProviderSwitch callbacks
- Pass handleSwitchProvider to useSessionListProps (gated behind encoreFeatures.virtuosos)
- Pass onSwitchProviderFromEdit to AppModals for EditAgentModal integration
- Render SwitchProviderModal in JSX near AccountSwitchModal and VirtuososModal
Co-Authored-By: Claude Opus 4.6
---
src/renderer/App.tsx | 87 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 87 insertions(+)
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 45094266c..e45a77bdc 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -46,6 +46,7 @@ import { EmptyStateView } from './components/EmptyStateView';
import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal';
import { AccountSwitchModal } from './components/AccountSwitchModal';
import { VirtuososModal } from './components/VirtuososModal';
+import { SwitchProviderModal } from './components/SwitchProviderModal';
// Lazy-loaded components for performance (rarely-used heavy modals)
// These are loaded on-demand when the user first opens them
@@ -132,6 +133,7 @@ import {
import type { TabCompletionSuggestion, TabCompletionFilter } from './hooks';
import { useMainPanelProps, useSessionListProps, useRightPanelProps } from './hooks/props';
import { useAgentListeners } from './hooks/agent/useAgentListeners';
+import { useProviderSwitch } from './hooks/agent/useProviderSwitch';
// Import contexts
import { useLayerStack } from './contexts/LayerStackContext';
@@ -152,6 +154,7 @@ import { ToastContainer } from './components/Toast';
import { gitService } from './services/git';
import { getSpeckitCommands } from './services/speckit';
import { getOpenSpecCommands } from './services/openspec';
+import { getAgentDisplayName } from './services/contextGroomer';
// Import prompts and synopsis parsing
import { autorunSynopsisPrompt, maestroSystemPrompt } from '../prompts';
@@ -860,6 +863,9 @@ function MaestroConsoleInner() {
usagePercent?: number;
} | null>(null);
+ // Provider Switch state
+ const [switchProviderSession, setSwitchProviderSession] = useState(null);
+
// Note: Git Diff State, Tour Overlay State, and Git Log Viewer State are from modalStore
// Note: Renaming state (editingGroupId/editingSessionId) and drag state (draggingSessionId)
@@ -2726,6 +2732,15 @@ You are taking over this conversation. Based on the context above, provide a bri
minContextUsagePercent,
} = useSummarizeAndContinue(activeSession ?? null);
+ const {
+ switchProvider,
+ transferState: providerSwitchState,
+ progress: providerSwitchProgress,
+ error: providerSwitchError,
+ cancelSwitch: cancelProviderSwitch,
+ reset: resetProviderSwitch,
+ } = useProviderSwitch();
+
// Handler for starting summarization (non-blocking - UI remains interactive)
const handleSummarizeAndContinue = useCallback(
(tabId?: string) => {
@@ -4888,6 +4903,61 @@ You are taking over this conversation. Based on the context above, provide a bri
[sessionsRef]
);
+ // Provider Switch handlers
+ const handleSwitchProvider = useCallback((sessionId: string) => {
+ const session = sessionsRef.current.find(s => s.id === sessionId);
+ if (session && session.toolType !== 'terminal') {
+ setSwitchProviderSession(session);
+ }
+ }, []);
+
+ const handleConfirmProviderSwitch = useCallback(async (request: {
+ targetProvider: ToolType;
+ groomContext: boolean;
+ archiveSource: boolean;
+ }) => {
+ if (!switchProviderSession) return;
+
+ const activeTab = getActiveTab(switchProviderSession);
+ if (!activeTab) return;
+
+ const result = await switchProvider({
+ sourceSession: switchProviderSession,
+ sourceTabId: activeTab.id,
+ targetProvider: request.targetProvider,
+ groomContext: request.groomContext,
+ archiveSource: request.archiveSource,
+ });
+
+ if (result.success && result.newSession) {
+ // Add the new session to state
+ setSessions(prev => [...prev, result.newSession!]);
+
+ // Mark source as archived if requested
+ if (request.archiveSource) {
+ setSessions(prev => prev.map(s =>
+ s.id === switchProviderSession.id
+ ? { ...s, archivedByMigration: true, migratedToSessionId: result.newSessionId }
+ : s
+ ));
+ }
+
+ // Navigate to the new session
+ setActiveSessionId(result.newSessionId!);
+
+ // Show success toast
+ notifyToast({
+ type: 'success',
+ title: 'Provider Switched',
+ message: `Switched to ${getAgentDisplayName(request.targetProvider)}`,
+ duration: 5_000,
+ });
+ }
+
+ // Close the modal
+ setSwitchProviderSession(null);
+ }, [switchProviderSession, switchProvider, setActiveSessionId]);
+
const handleRenameTab = useCallback(
(newName: string) => {
if (!activeSession || !renameTabId) return;
@@ -8610,6 +8680,7 @@ You are taking over this conversation. Based on the context above, provide a bri
handleOpenWorktreeConfigSession,
handleDeleteWorktreeSession,
handleToggleWorktreeExpanded,
+ handleSwitchProvider: encoreFeatures.virtuosos ? handleSwitchProvider : undefined,
openWizardModal,
handleStartTour,
@@ -8883,6 +8954,7 @@ You are taking over this conversation. Based on the context above, provide a bri
onCloseEditAgentModal={handleCloseEditAgentModal}
onSaveEditAgent={handleSaveEditAgent}
editAgentSession={editAgentSession}
+ onSwitchProviderFromEdit={encoreFeatures.virtuosos && editAgentSession ? () => handleSwitchProvider(editAgentSession.id) : undefined}
renameSessionModalOpen={renameInstanceModalOpen}
renameSessionValue={renameInstanceValue}
setRenameSessionValue={setRenameInstanceValue}
@@ -9587,6 +9659,21 @@ You are taking over this conversation. Based on the context above, provide a bri
/>
)}
+ {/* Provider Switch Modal */}
+ {encoreFeatures.virtuosos && switchProviderSession && (
+ {
+ setSwitchProviderSession(null);
+ resetProviderSwitch();
+ }}
+ sourceSession={switchProviderSession}
+ sourceTabId={getActiveTab(switchProviderSession)?.id || ''}
+ onConfirmSwitch={handleConfirmProviderSwitch}
+ />
+ )}
+
{/* --- EMPTY STATE VIEW (when no sessions) --- */}
{sessions.length === 0 && !isMobileLandscape ? (
Date: Thu, 19 Feb 2026 03:11:05 -0500
Subject: [PATCH 40/59] MAESTRO: add archive visual treatment for
provider-switched sessions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sessions with archivedByMigration render at 40% opacity, show a grey
hollow status dot (no animation), and display "Provider switched —
archived" indicator text below the session name.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/components/SessionItem.tsx | 55 ++++++++++++++++---------
1 file changed, 35 insertions(+), 20 deletions(-)
diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx
index 06ef6f0dc..a36bec4b6 100644
--- a/src/renderer/components/SessionItem.tsx
+++ b/src/renderer/components/SessionItem.tsx
@@ -121,6 +121,7 @@ export const SessionItem = memo(function SessionItem({
: isKeyboardSelected
? theme.colors.bgActivity + '40'
: 'transparent',
+ opacity: session.archivedByMigration ? 0.4 : undefined,
}}
>
{/* Left side: Session name and metadata */}
@@ -202,6 +203,16 @@ export const SessionItem = memo(function SessionItem({
)}
)}
+
+ {/* Migration archive indicator */}
+ {session.archivedByMigration && (
+
+ Provider switched — archived
+
+ )}
{/* Right side: Indicators and actions */}
@@ -331,30 +342,34 @@ export const SessionItem = memo(function SessionItem({
{/* AI Status Indicator with Unread Badge - ml-auto ensures it aligns to right edge */}
{/* Unread Notification Badge */}
From 4c1d9e274a012deb6084019499e6fbd2cceff20b Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 03:17:58 -0500
Subject: [PATCH 41/59] MAESTRO: add Providers tab to VirtuososModal with
status grid, failover config, and migration history
- Extend VirtuososModal from 2 tabs to 3: Accounts, Providers, Usage
- Rename "Configuration" tab to "Accounts" for clarity
- Create ProviderPanel component with three sections:
1. Provider Status Grid showing detected agents with availability and session counts
2. Failover Configuration with toggles, thresholds, and ordered fallback list
3. Migration History timeline of past provider switches
- Update tests for new tab structure and add Providers tab coverage
Co-Authored-By: Claude Opus 4.6
---
.../components/VirtuososModal.test.tsx | 87 ++-
src/renderer/components/ProviderPanel.tsx | 657 ++++++++++++++++++
src/renderer/components/VirtuososModal.tsx | 18 +-
3 files changed, 743 insertions(+), 19 deletions(-)
create mode 100644 src/renderer/components/ProviderPanel.tsx
diff --git a/src/__tests__/renderer/components/VirtuososModal.test.tsx b/src/__tests__/renderer/components/VirtuososModal.test.tsx
index 4bf87fbc8..69d55d5ea 100644
--- a/src/__tests__/renderer/components/VirtuososModal.test.tsx
+++ b/src/__tests__/renderer/components/VirtuososModal.test.tsx
@@ -28,6 +28,10 @@ vi.mock('../../../renderer/components/AccountsPanel', () => ({
AccountsPanel: () => AccountsPanel
,
}));
+vi.mock('../../../renderer/components/ProviderPanel', () => ({
+ ProviderPanel: () => ProviderPanel
,
+}));
+
vi.mock('../../../renderer/components/VirtuosoUsageView', () => ({
VirtuosoUsageView: () => (
VirtuosoUsageView
@@ -77,21 +81,38 @@ describe('VirtuososModal', () => {
expect(container.firstChild).toBeNull();
});
- it('renders Configuration tab by default', () => {
+ it('renders Accounts tab by default', () => {
render( );
- const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ const accountsTab = screen.getByRole('tab', { name: /Accounts/i });
+ const providersTab = screen.getByRole('tab', { name: /Providers/i });
const usageTab = screen.getByRole('tab', { name: /Usage/i });
- expect(configTab).toBeDefined();
+ expect(accountsTab).toBeDefined();
+ expect(providersTab).toBeDefined();
expect(usageTab).toBeDefined();
- expect(configTab.getAttribute('aria-selected')).toBe('true');
+ expect(accountsTab.getAttribute('aria-selected')).toBe('true');
+ expect(providersTab.getAttribute('aria-selected')).toBe('false');
expect(usageTab.getAttribute('aria-selected')).toBe('false');
expect(screen.getByTestId('accounts-panel')).toBeDefined();
});
+ it('switches to Providers tab on click', () => {
+ render( );
+
+ const providersTab = screen.getByRole('tab', { name: /Providers/i });
+ fireEvent.click(providersTab);
+
+ expect(providersTab.getAttribute('aria-selected')).toBe('true');
+
+ const accountsTab = screen.getByRole('tab', { name: /Accounts/i });
+ expect(accountsTab.getAttribute('aria-selected')).toBe('false');
+
+ expect(screen.getByTestId('provider-panel')).toBeDefined();
+ });
+
it('switches to Usage tab on click', () => {
render( );
@@ -100,8 +121,8 @@ describe('VirtuososModal', () => {
expect(usageTab.getAttribute('aria-selected')).toBe('true');
- const configTab = screen.getByRole('tab', { name: /Configuration/i });
- expect(configTab.getAttribute('aria-selected')).toBe('false');
+ const accountsTab = screen.getByRole('tab', { name: /Accounts/i });
+ expect(accountsTab.getAttribute('aria-selected')).toBe('false');
expect(screen.getByTestId('virtuoso-usage-view')).toBeDefined();
});
@@ -109,11 +130,24 @@ describe('VirtuososModal', () => {
it('cycles tabs with Cmd+Shift+]', async () => {
render( );
- const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ const accountsTab = screen.getByRole('tab', { name: /Accounts/i });
+ const providersTab = screen.getByRole('tab', { name: /Providers/i });
const usageTab = screen.getByRole('tab', { name: /Usage/i });
- expect(configTab.getAttribute('aria-selected')).toBe('true');
+ expect(accountsTab.getAttribute('aria-selected')).toBe('true');
+ // Accounts -> Providers
+ fireEvent.keyDown(window, {
+ key: ']',
+ metaKey: true,
+ shiftKey: true,
+ });
+
+ await waitFor(() => {
+ expect(providersTab.getAttribute('aria-selected')).toBe('true');
+ });
+
+ // Providers -> Usage
fireEvent.keyDown(window, {
key: ']',
metaKey: true,
@@ -132,15 +166,28 @@ describe('VirtuososModal', () => {
fireEvent.click(usageTab);
expect(usageTab.getAttribute('aria-selected')).toBe('true');
+ // Usage -> Providers
fireEvent.keyDown(window, {
key: '[',
metaKey: true,
shiftKey: true,
});
- const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ const providersTab = screen.getByRole('tab', { name: /Providers/i });
await waitFor(() => {
- expect(configTab.getAttribute('aria-selected')).toBe('true');
+ expect(providersTab.getAttribute('aria-selected')).toBe('true');
+ });
+
+ // Providers -> Accounts
+ fireEvent.keyDown(window, {
+ key: '[',
+ metaKey: true,
+ shiftKey: true,
+ });
+
+ const accountsTab = screen.getByRole('tab', { name: /Accounts/i });
+ await waitFor(() => {
+ expect(accountsTab.getAttribute('aria-selected')).toBe('true');
});
});
@@ -176,9 +223,25 @@ describe('VirtuososModal', () => {
shiftKey: true,
});
- const configTab = screen.getByRole('tab', { name: /Configuration/i });
+ const accountsTab = screen.getByRole('tab', { name: /Accounts/i });
+ await waitFor(() => {
+ expect(accountsTab.getAttribute('aria-selected')).toBe('true');
+ });
+ });
+
+ it('wraps around when cycling before first tab', async () => {
+ render( );
+
+ // Accounts is first, press [ to wrap to Usage (last)
+ fireEvent.keyDown(window, {
+ key: '[',
+ metaKey: true,
+ shiftKey: true,
+ });
+
+ const usageTab = screen.getByRole('tab', { name: /Usage/i });
await waitFor(() => {
- expect(configTab.getAttribute('aria-selected')).toBe('true');
+ expect(usageTab.getAttribute('aria-selected')).toBe('true');
});
});
});
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
new file mode 100644
index 000000000..64fc6ada4
--- /dev/null
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -0,0 +1,657 @@
+/**
+ * ProviderPanel - Provider status, failover configuration, and migration history
+ *
+ * Three sections:
+ * 1. Provider Status Grid — shows detected agents with availability and session counts
+ * 2. Failover Configuration — controls for automatic provider failover
+ * 3. Migration History — timeline of past provider switches
+ */
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ ChevronDown,
+ ChevronUp,
+ Plus,
+ X,
+ ArrowRightLeft,
+} from 'lucide-react';
+import type { Theme, Session, AgentConfig } from '../types';
+import type { ToolType } from '../../shared/types';
+import type { ProviderSwitchConfig } from '../../shared/account-types';
+import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
+import { getAgentIcon } from '../constants/agentIcons';
+import { getAgentDisplayName } from '../services/contextGroomer';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ProviderPanelProps {
+ theme: Theme;
+ sessions?: Session[];
+}
+
+interface ProviderStatus {
+ id: ToolType;
+ name: string;
+ icon: string;
+ available: boolean;
+ activeSessionCount: number;
+}
+
+interface MigrationEntry {
+ timestamp: number;
+ sessionName: string;
+ sourceProvider: ToolType;
+ targetProvider: ToolType;
+ generation: number;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const ERROR_WINDOW_OPTIONS = [
+ { label: '1 minute', value: 1 * 60 * 1000 },
+ { label: '2 minutes', value: 2 * 60 * 1000 },
+ { label: '5 minutes', value: 5 * 60 * 1000 },
+ { label: '10 minutes', value: 10 * 60 * 1000 },
+ { label: '15 minutes', value: 15 * 60 * 1000 },
+];
+
+const MIGRATION_HISTORY_LIMIT = 20;
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function formatMigrationTime(timestamp: number): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffHours = diffMs / (1000 * 60 * 60);
+
+ if (diffHours < 24) {
+ return date.toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ }
+
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+function ordinalSuffix(n: number): string {
+ const s = ['th', 'st', 'nd', 'rd'];
+ const v = n % 100;
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
+ const [providers, setProviders] = useState([]);
+ const [config, setConfig] = useState(DEFAULT_PROVIDER_SWITCH_CONFIG);
+ const [showMoreHistory, setShowMoreHistory] = useState(false);
+
+ // ── Load providers ──────────────────────────────────────────────────
+ useEffect(() => {
+ async function detectProviders() {
+ try {
+ const agents: AgentConfig[] = await window.maestro.agents.detect();
+ const statuses: ProviderStatus[] = agents
+ .filter((a) => a.id !== 'terminal' && !a.hidden)
+ .map((agent) => {
+ const toolType = agent.id as ToolType;
+ const activeCount = sessions.filter(
+ (s) => s.toolType === toolType && !s.archivedByMigration
+ ).length;
+ return {
+ id: toolType,
+ name: getAgentDisplayName(toolType),
+ icon: getAgentIcon(toolType),
+ available: agent.available,
+ activeSessionCount: activeCount,
+ };
+ });
+ setProviders(statuses);
+ } catch (err) {
+ console.error('Failed to detect agents:', err);
+ }
+ }
+ detectProviders();
+ }, [sessions]);
+
+ // ── Load failover config ────────────────────────────────────────────
+ useEffect(() => {
+ async function loadConfig() {
+ try {
+ const saved = await window.maestro.settings.get('providerSwitchConfig');
+ if (saved && typeof saved === 'object') {
+ setConfig({ ...DEFAULT_PROVIDER_SWITCH_CONFIG, ...(saved as Partial) });
+ }
+ } catch {
+ // Use defaults
+ }
+ }
+ loadConfig();
+ }, []);
+
+ const saveConfig = useCallback(async (updates: Partial) => {
+ const updated = { ...config, ...updates };
+ setConfig(updated);
+ try {
+ await window.maestro.settings.set('providerSwitchConfig', updated);
+ } catch (err) {
+ console.error('Failed to save provider switch config:', err);
+ }
+ }, [config]);
+
+ // ── Build migration history ─────────────────────────────────────────
+ const migrations: MigrationEntry[] = React.useMemo(() => {
+ const entries: MigrationEntry[] = [];
+
+ for (const session of sessions) {
+ if (session.migratedFromSessionId && session.migratedAt) {
+ // This session was created by migration — find source
+ const source = sessions.find((s) => s.id === session.migratedFromSessionId);
+ if (source) {
+ entries.push({
+ timestamp: session.migratedAt,
+ sessionName: session.name || 'Unnamed Agent',
+ sourceProvider: source.toolType as ToolType,
+ targetProvider: session.toolType as ToolType,
+ generation: session.migrationGeneration || 1,
+ });
+ }
+ }
+ }
+
+ entries.sort((a, b) => b.timestamp - a.timestamp);
+ return entries;
+ }, [sessions]);
+
+ const visibleMigrations = showMoreHistory
+ ? migrations
+ : migrations.slice(0, MIGRATION_HISTORY_LIMIT);
+ const hasMoreMigrations = migrations.length > MIGRATION_HISTORY_LIMIT;
+
+ // ── Fallback provider management ────────────────────────────────────
+ const availableForFallback = providers.filter(
+ (p) => !config.fallbackProviders.includes(p.id)
+ );
+
+ const handleAddFallback = (toolType: ToolType) => {
+ saveConfig({ fallbackProviders: [...config.fallbackProviders, toolType] });
+ };
+
+ const handleRemoveFallback = (toolType: ToolType) => {
+ saveConfig({
+ fallbackProviders: config.fallbackProviders.filter((p) => p !== toolType),
+ });
+ };
+
+ const handleMoveFallback = (index: number, direction: 'up' | 'down') => {
+ const list = [...config.fallbackProviders];
+ const swapIndex = direction === 'up' ? index - 1 : index + 1;
+ if (swapIndex < 0 || swapIndex >= list.length) return;
+ [list[index], list[swapIndex]] = [list[swapIndex], list[index]];
+ saveConfig({ fallbackProviders: list });
+ };
+
+ // ── Styles ──────────────────────────────────────────────────────────
+ const sectionStyle: React.CSSProperties = {
+ backgroundColor: theme.colors.bgSidebar,
+ borderRadius: 8,
+ padding: '16px',
+ marginBottom: 16,
+ };
+
+ const sectionTitleStyle: React.CSSProperties = {
+ color: theme.colors.textMain,
+ fontSize: 13,
+ fontWeight: 600,
+ marginBottom: 12,
+ };
+
+ const labelStyle: React.CSSProperties = {
+ color: theme.colors.textMain,
+ fontSize: 12,
+ };
+
+ const dimStyle: React.CSSProperties = {
+ color: theme.colors.textDim,
+ fontSize: 11,
+ };
+
+ // ── Render ───────────────────────────────────────────────────────────
+ return (
+
+ {/* Provider Status Grid */}
+
+
Provider Status
+
+ {providers.map((provider) => (
+
+
+ {provider.icon}
+
+ {provider.name}
+
+
+
+
+
+ {provider.available ? 'Available' : 'Not Installed'}
+
+
+ {provider.activeSessionCount} active{' '}
+ {provider.activeSessionCount === 1 ? 'session' : 'sessions'}
+
+ 0 errors (5m)
+
+
+ ))}
+ {providers.length === 0 && (
+
No providers detected
+ )}
+
+
+
+ {/* Failover Configuration */}
+
+
Provider Failover
+
+ {/* Enable automatic failover toggle */}
+
+
+
Enable automatic failover
+
+ When a provider hits repeated errors, suggest switching to an
+ alternative provider.
+
+
+
saveConfig({ enabled: !config.enabled })}
+ className="w-8 h-4 rounded-full transition-colors relative flex-shrink-0 ml-3"
+ style={{
+ backgroundColor: config.enabled
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+ {/* Prompt before switching toggle */}
+
+
+
Prompt before switching
+
+ Ask for confirmation before auto-switching. Uncheck for fully
+ automatic failover.
+
+
+
+ saveConfig({ promptBeforeSwitch: !config.promptBeforeSwitch })
+ }
+ className="w-8 h-4 rounded-full transition-colors relative flex-shrink-0 ml-3"
+ style={{
+ backgroundColor: config.promptBeforeSwitch
+ ? theme.colors.accent
+ : theme.colors.bgActivity,
+ }}
+ >
+
+
+
+
+ {/* Error threshold and window */}
+
+
+ Error threshold:
+
+ saveConfig({ errorThreshold: parseInt(e.target.value) })
+ }
+ style={{
+ backgroundColor: theme.colors.bgMain,
+ color: theme.colors.textMain,
+ border: `1px solid ${theme.colors.border}`,
+ borderRadius: 4,
+ padding: '2px 6px',
+ fontSize: 12,
+ }}
+ >
+ {Array.from({ length: 10 }, (_, i) => i + 1).map((n) => (
+
+ {n}
+
+ ))}
+
+ consecutive errors
+
+
+ Error window:
+
+ saveConfig({ errorWindowMs: parseInt(e.target.value) })
+ }
+ style={{
+ backgroundColor: theme.colors.bgMain,
+ color: theme.colors.textMain,
+ border: `1px solid ${theme.colors.border}`,
+ borderRadius: 4,
+ padding: '2px 6px',
+ fontSize: 12,
+ }}
+ >
+ {ERROR_WINDOW_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+ {/* Fallback priority list */}
+
+
+ Fallback priority:
+
+ {config.fallbackProviders.length === 0 && (
+
+ No fallback providers configured
+
+ )}
+ {config.fallbackProviders.map((toolType, index) => (
+
+ {index + 1}.
+
+ {getAgentIcon(toolType)}
+
+
+ {getAgentDisplayName(toolType)}
+
+ handleMoveFallback(index, 'up')}
+ disabled={index === 0}
+ className="p-0.5 rounded transition-colors"
+ style={{
+ color:
+ index === 0
+ ? theme.colors.textDim + '40'
+ : theme.colors.textDim,
+ }}
+ title="Move up"
+ >
+
+
+ handleMoveFallback(index, 'down')}
+ disabled={
+ index === config.fallbackProviders.length - 1
+ }
+ className="p-0.5 rounded transition-colors"
+ style={{
+ color:
+ index === config.fallbackProviders.length - 1
+ ? theme.colors.textDim + '40'
+ : theme.colors.textDim,
+ }}
+ title="Move down"
+ >
+
+
+ handleRemoveFallback(toolType)}
+ className="p-0.5 rounded transition-colors"
+ style={{ color: theme.colors.textDim }}
+ title="Remove"
+ >
+
+
+
+ ))}
+
+ {/* Add provider dropdown */}
+ {availableForFallback.length > 0 && (
+
+ )}
+
+
+
+ {/* Migration History */}
+
+
Migration History
+ {migrations.length === 0 ? (
+
+ No provider switches yet
+
+ ) : (
+
+ {visibleMigrations.map((entry, i) => (
+
+
+ {formatMigrationTime(entry.timestamp)}
+
+
+ {entry.sessionName}:{' '}
+
+ {getAgentDisplayName(entry.sourceProvider)}
+
+ {' '}
+
+ {' '}
+
+ {getAgentDisplayName(entry.targetProvider)}
+
+
+ {entry.generation > 1 && (
+
+ {ordinalSuffix(entry.generation)} switch
+
+ )}
+
+ ))}
+ {hasMoreMigrations && !showMoreHistory && (
+
setShowMoreHistory(true)}
+ className="text-xs mt-2 hover:underline"
+ style={{ color: theme.colors.accent }}
+ >
+ Show more ({migrations.length - MIGRATION_HISTORY_LIMIT}{' '}
+ remaining)
+
+ )}
+ {showMoreHistory && hasMoreMigrations && (
+
setShowMoreHistory(false)}
+ className="text-xs mt-2 hover:underline"
+ style={{ color: theme.colors.accent }}
+ >
+ Show less
+
+ )}
+
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// Sub-components
+// ============================================================================
+
+function AddProviderDropdown({
+ theme,
+ providers,
+ onAdd,
+}: {
+ theme: Theme;
+ providers: ProviderStatus[];
+ onAdd: (toolType: ToolType) => void;
+}) {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="flex items-center gap-1 text-xs px-3 py-1.5 rounded transition-colors"
+ style={{
+ color: theme.colors.accent,
+ backgroundColor: `${theme.colors.accent}10`,
+ border: `1px solid ${theme.colors.accent}30`,
+ }}
+ >
+
+ Add provider
+
+ {isOpen && (
+
+ {providers.map((p) => (
+ {
+ onAdd(p.id);
+ setIsOpen(false);
+ }}
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors"
+ style={{ color: theme.colors.textMain }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+ {p.icon}
+ {p.name}
+ {!p.available && (
+
+ (not installed)
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default ProviderPanel;
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
index b01b14d55..a74ee7ef5 100644
--- a/src/renderer/components/VirtuososModal.tsx
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -1,23 +1,26 @@
/**
* VirtuososModal - Standalone modal for account (Virtuoso) management
*
- * Two-tab layout:
- * 1. Configuration — AccountsPanel (account CRUD, discovery, plan presets, auto-switch)
- * 2. Usage — VirtuosoUsageView (real-time metrics, predictions, history, throttle events)
+ * Three-tab layout:
+ * 1. Accounts — AccountsPanel (account CRUD, discovery, plan presets, auto-switch)
+ * 2. Providers — ProviderPanel (provider status, failover config, migration history)
+ * 3. Usage — VirtuosoUsageView (real-time metrics, predictions, history, throttle events)
*/
import React, { useState, useEffect } from 'react';
-import { Users, Settings, BarChart3 } from 'lucide-react';
+import { Users, Settings, BarChart3, ArrowRightLeft } from 'lucide-react';
import { AccountsPanel } from './AccountsPanel';
+import { ProviderPanel } from './ProviderPanel';
import { VirtuosoUsageView } from './VirtuosoUsageView';
import { Modal } from './ui/Modal';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import type { Theme, Session } from '../types';
-type VirtuosoTab = 'config' | 'usage';
+type VirtuosoTab = 'config' | 'providers' | 'usage';
const VIRTUOSO_TABS: { value: VirtuosoTab; label: string; icon: typeof Settings }[] = [
- { value: 'config', label: 'Configuration', icon: Settings },
+ { value: 'config', label: 'Accounts', icon: Settings },
+ { value: 'providers', label: 'Providers', icon: ArrowRightLeft },
{ value: 'usage', label: 'Usage', icon: BarChart3 },
];
@@ -57,7 +60,7 @@ export function VirtuososModal({ isOpen, onClose, theme, sessions }: VirtuososMo
if (!isOpen) return null;
- const modalWidth = activeTab === 'usage' ? 900 : 720;
+ const modalWidth = activeTab === 'usage' ? 900 : activeTab === 'providers' ? 860 : 720;
return (
}
+ {activeTab === 'providers' && }
{activeTab === 'usage' && }
);
From a84dc9300a0c2a61d5e3da33f3e3b47b3526e08a Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 03:33:04 -0500
Subject: [PATCH 42/59] MAESTRO: add automated provider failover with
ProviderErrorTracker, IPC API, and App.tsx wiring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements VSWITCH-08: sliding-window error tracker that monitors consecutive
agent errors per session and emits failover suggestions when the configured
threshold is reached. The renderer subscribes to these events and opens the
SwitchProviderModal (or shows a toast for auto-switch).
- ProviderErrorTracker class with configurable threshold, window, and fallback list
- Only counts recoverable, provider-level errors (rate_limited, network_error,
agent_crashed, auth_expired) — not token_exhaustion or session_not_found
- IPC handlers (providers:get-error-stats, get-all-error-stats, clear-session-errors)
- Preload API (window.maestro.providers namespace)
- Error listener integration: feeds agent errors into tracker
- Query-complete listener: resets error count on successful response
- App.tsx: failover suggestion subscription gated by encoreFeatures.virtuosos
- Clears provider error state on successful provider switch
- 21 unit tests covering all tracker behaviors
Co-Authored-By: Claude Opus 4.6
---
src/main/index.ts | 41 ++
src/main/ipc/handlers/index.ts | 3 +
src/main/ipc/handlers/providers.ts | 66 +++
src/main/preload/index.ts | 10 +
src/main/preload/providers.ts | 43 ++
src/main/process-listeners/error-listener.ts | 20 +-
src/main/process-listeners/index.ts | 12 +-
src/main/process-listeners/types.ts | 3 +
.../__tests__/provider-error-tracker.test.ts | 424 ++++++++++++++++++
src/main/providers/provider-error-tracker.ts | 214 +++++++++
src/renderer/App.tsx | 43 ++
src/renderer/global.d.ts | 32 ++
src/shared/account-types.ts | 32 +-
13 files changed, 938 insertions(+), 5 deletions(-)
create mode 100644 src/main/ipc/handlers/providers.ts
create mode 100644 src/main/preload/providers.ts
create mode 100644 src/main/providers/__tests__/provider-error-tracker.test.ts
create mode 100644 src/main/providers/provider-error-tracker.ts
diff --git a/src/main/index.ts b/src/main/index.ts
index 57bf1d051..670efe6c3 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -53,6 +53,7 @@ import {
registerAgentErrorHandlers,
registerDirectorNotesHandlers,
registerAccountHandlers,
+ registerProviderHandlers,
registerWakatimeHandlers,
setupLoggerEventForwarding,
cleanupAllGroomingSessions,
@@ -64,6 +65,8 @@ import { AccountThrottleHandler } from './accounts/account-throttle-handler';
import { AccountAuthRecovery } from './accounts/account-auth-recovery';
import { AccountRecoveryPoller } from './accounts/account-recovery-poller';
import { AccountSwitcher } from './accounts/account-switcher';
+import { ProviderErrorTracker } from './providers/provider-error-tracker';
+import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../shared/account-types';
import { getAccountStore } from './stores';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import {
@@ -185,6 +188,13 @@ if (store.get('wakatimeEnabled', false)) {
wakatimeManager.ensureCliInstalled();
}
+// Update provider error tracker when failover config changes
+store.onDidChange('providerSwitchConfig' as any, (newValue: any) => {
+ if (providerErrorTracker && newValue && typeof newValue === 'object') {
+ providerErrorTracker.updateConfig({ ...DEFAULT_PROVIDER_SWITCH_CONFIG, ...newValue });
+ }
+});
+
// Auto-install WakaTime CLI when user enables the feature
store.onDidChange('wakatimeEnabled', (newValue) => {
if (newValue === true) {
@@ -254,6 +264,7 @@ let accountThrottleHandler: AccountThrottleHandler | null = null;
let accountAuthRecovery: AccountAuthRecovery | null = null;
let accountRecoveryPoller: AccountRecoveryPoller | null = null;
let accountSwitcher: AccountSwitcher | null = null;
+let providerErrorTracker: ProviderErrorTracker | null = null;
// Create safeSend with dependency injection (Phase 2 refactoring)
const safeSend = createSafeSend(() => mainWindow);
@@ -425,6 +436,30 @@ app.whenReady().then(async () => {
}
}
+ // Initialize provider error tracker for Virtuosos failover detection
+ try {
+ const savedConfig = store.get('providerSwitchConfig') as any;
+ const config = savedConfig
+ ? { ...DEFAULT_PROVIDER_SWITCH_CONFIG, ...savedConfig }
+ : DEFAULT_PROVIDER_SWITCH_CONFIG;
+ providerErrorTracker = new ProviderErrorTracker(
+ config,
+ (suggestion) => {
+ // Send failover suggestion to renderer
+ safeSend('provider:failover-suggest', suggestion);
+ },
+ (sessionId) => {
+ // Resolve session name from sessions store
+ const sessions = sessionsStore.get('sessions', []) as any[];
+ const session = sessions.find((s: any) => s.id === sessionId);
+ return session?.name || sessionId;
+ },
+ );
+ logger.info('Provider error tracker initialized', 'Startup');
+ } catch (error) {
+ logger.error(`Failed to initialize provider error tracker: ${error}`, 'Startup');
+ }
+
// Set up IPC handlers
logger.debug('Setting up IPC handlers', 'Startup');
setupIpcHandlers();
@@ -652,6 +687,11 @@ function setupIpcHandlers() {
getAccountSwitcher: () => accountSwitcher,
});
+ // Register Provider Error Tracking handlers (stats queries, error clearing)
+ registerProviderHandlers({
+ getProviderErrorTracker: () => providerErrorTracker,
+ });
+
// Register Document Graph handlers for file watching
registerDocumentGraphHandlers({
getMainWindow: () => mainWindow,
@@ -794,6 +834,7 @@ function setupProcessListeners() {
getAccountRegistry: () => accountRegistry,
getThrottleHandler: () => accountThrottleHandler,
getAuthRecovery: () => accountAuthRecovery,
+ getProviderErrorTracker: () => providerErrorTracker,
debugLog,
patterns: {
REGEX_MODERATOR_SESSION,
diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts
index 5a216220d..9a61ff9b6 100644
--- a/src/main/ipc/handlers/index.ts
+++ b/src/main/ipc/handlers/index.ts
@@ -53,6 +53,7 @@ import { registerAgentErrorHandlers } from './agent-error';
import { registerTabNamingHandlers, TabNamingHandlerDependencies } from './tabNaming';
import { registerDirectorNotesHandlers, DirectorNotesHandlerDependencies } from './director-notes';
import { registerAccountHandlers, AccountHandlerDependencies } from './accounts';
+import { registerProviderHandlers, ProviderHandlerDependencies } from './providers';
import { registerWakatimeHandlers } from './wakatime';
import { AgentDetector } from '../../agents';
import type { AccountRegistry } from '../../accounts/account-registry';
@@ -100,6 +101,8 @@ export { registerDirectorNotesHandlers };
export type { DirectorNotesHandlerDependencies };
export { registerAccountHandlers };
export type { AccountHandlerDependencies };
+export { registerProviderHandlers };
+export type { ProviderHandlerDependencies };
export { registerWakatimeHandlers };
export type { AgentsHandlerDependencies };
export type { ProcessHandlerDependencies };
diff --git a/src/main/ipc/handlers/providers.ts b/src/main/ipc/handlers/providers.ts
new file mode 100644
index 000000000..2a649b54a
--- /dev/null
+++ b/src/main/ipc/handlers/providers.ts
@@ -0,0 +1,66 @@
+/**
+ * Provider Error Tracking IPC Handlers
+ *
+ * Registers IPC handlers for provider error stats queries:
+ * - Get error stats for a specific provider
+ * - Get error stats for all providers
+ * - Clear error tracking for a session (after manual provider switch)
+ */
+
+import { ipcMain } from 'electron';
+import type { ProviderErrorTracker } from '../../providers/provider-error-tracker';
+import type { ToolType } from '../../../shared/types';
+import type { ProviderErrorStats } from '../../../shared/account-types';
+import { logger } from '../../utils/logger';
+
+const LOG_CONTEXT = 'Providers';
+
+/**
+ * Dependencies for provider error tracking handlers
+ */
+export interface ProviderHandlerDependencies {
+ getProviderErrorTracker: () => ProviderErrorTracker | null;
+}
+
+/**
+ * Register all provider error tracking IPC handlers.
+ */
+export function registerProviderHandlers(deps: ProviderHandlerDependencies): void {
+ const { getProviderErrorTracker } = deps;
+
+ // Get error stats for a specific provider
+ ipcMain.handle(
+ 'providers:get-error-stats',
+ async (_event, toolType: string): Promise => {
+ const tracker = getProviderErrorTracker();
+ if (!tracker) return null;
+ return tracker.getProviderStats(toolType as ToolType);
+ }
+ );
+
+ // Get error stats for all providers
+ ipcMain.handle(
+ 'providers:get-all-error-stats',
+ async (): Promise> => {
+ const tracker = getProviderErrorTracker();
+ if (!tracker) return {};
+ const stats = tracker.getAllStats();
+ const result: Record = {};
+ for (const [key, value] of stats) {
+ result[key] = value;
+ }
+ return result;
+ }
+ );
+
+ // Clear error tracking for a session (e.g., after manual provider switch)
+ ipcMain.handle(
+ 'providers:clear-session-errors',
+ async (_event, sessionId: string): Promise => {
+ const tracker = getProviderErrorTracker();
+ if (!tracker) return;
+ logger.debug('Clearing provider errors for session', LOG_CONTEXT, { sessionId });
+ tracker.clearSession(sessionId);
+ }
+ );
+}
diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts
index 3820973d8..22632a755 100644
--- a/src/main/preload/index.ts
+++ b/src/main/preload/index.ts
@@ -50,6 +50,7 @@ import { createSymphonyApi } from './symphony';
import { createTabNamingApi } from './tabNaming';
import { createDirectorNotesApi } from './directorNotes';
import { createAccountsApi } from './accounts';
+import { createProvidersApi } from './providers';
import { createWakatimeApi } from './wakatime';
// Expose protected methods that allow the renderer process to use
@@ -190,6 +191,9 @@ contextBridge.exposeInMainWorld('maestro', {
// Account Multiplexing API (usage events, limit warnings)
accounts: createAccountsApi(),
+ // Provider Error Tracking API (error stats, failover suggestions)
+ providers: createProvidersApi(),
+
// WakaTime API (CLI check, API key validation)
wakatime: createWakatimeApi(),
});
@@ -265,6 +269,8 @@ export {
createDirectorNotesApi,
// Accounts
createAccountsApi,
+ // Providers
+ createProvidersApi,
// WakaTime
createWakatimeApi,
};
@@ -477,6 +483,10 @@ export type {
AccountUsageUpdate,
AccountLimitEvent,
} from './accounts';
+export type {
+ // From providers
+ ProvidersApi,
+} from './providers';
export type {
// From wakatime
WakatimeApi,
diff --git a/src/main/preload/providers.ts b/src/main/preload/providers.ts
new file mode 100644
index 000000000..1e2f1ad01
--- /dev/null
+++ b/src/main/preload/providers.ts
@@ -0,0 +1,43 @@
+/**
+ * Preload API for provider error tracking
+ *
+ * Provides the window.maestro.providers namespace for:
+ * - Querying error stats per provider (for ProviderPanel health dashboard)
+ * - Clearing error tracking for a session (after manual provider switch)
+ * - Subscribing to failover suggestion events
+ */
+
+import { ipcRenderer } from 'electron';
+import type { ProviderErrorStats, FailoverSuggestion } from '../../shared/account-types';
+
+/**
+ * Creates the providers API object for preload exposure
+ */
+export function createProvidersApi() {
+ return {
+ /** Get error stats for a specific provider */
+ getErrorStats: (toolType: string): Promise =>
+ ipcRenderer.invoke('providers:get-error-stats', toolType),
+
+ /** Get error stats for all providers */
+ getAllErrorStats: (): Promise> =>
+ ipcRenderer.invoke('providers:get-all-error-stats'),
+
+ /** Clear error tracking for a session (e.g., after manual provider switch) */
+ clearSessionErrors: (sessionId: string): Promise =>
+ ipcRenderer.invoke('providers:clear-session-errors', sessionId),
+
+ /** Subscribe to failover suggestion events */
+ onFailoverSuggest: (handler: (data: FailoverSuggestion) => void): (() => void) => {
+ const wrappedHandler = (_event: Electron.IpcRendererEvent, data: FailoverSuggestion) =>
+ handler(data);
+ ipcRenderer.on('provider:failover-suggest', wrappedHandler);
+ return () => ipcRenderer.removeListener('provider:failover-suggest', wrappedHandler);
+ },
+ };
+}
+
+/**
+ * TypeScript type for the providers API
+ */
+export type ProvidersApi = ReturnType;
diff --git a/src/main/process-listeners/error-listener.ts b/src/main/process-listeners/error-listener.ts
index 9f4e5eba2..57038926a 100644
--- a/src/main/process-listeners/error-listener.ts
+++ b/src/main/process-listeners/error-listener.ts
@@ -7,17 +7,19 @@
*/
import type { ProcessManager } from '../process-manager';
-import type { AgentError } from '../../shared/types';
+import type { AgentError, ToolType } from '../../shared/types';
import type { ProcessListenerDependencies } from './types';
import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
import type { AccountAuthRecovery } from '../accounts/account-auth-recovery';
import type { AccountRegistry } from '../accounts/account-registry';
+import type { ProviderErrorTracker } from '../providers/provider-error-tracker';
import { REGEX_SESSION_SUFFIX } from '../constants';
/**
* Sets up the agent-error listener.
* Handles logging and forwarding of agent errors to renderer.
* Optionally triggers throttle handling or auth recovery for account multiplexing.
+ * Optionally feeds errors into the ProviderErrorTracker for failover detection.
*/
export function setupErrorListener(
processManager: ProcessManager,
@@ -26,7 +28,8 @@ export function setupErrorListener(
getAccountRegistry: () => AccountRegistry | null;
getThrottleHandler: () => AccountThrottleHandler | null;
getAuthRecovery: () => AccountAuthRecovery | null;
- }
+ },
+ providerErrorTracker?: ProviderErrorTracker,
): void {
const { safeSend, logger } = deps;
@@ -41,6 +44,19 @@ export function setupErrorListener(
});
safeSend('agent:error', sessionId, agentError);
+ // Feed into provider error tracker for failover detection
+ if (providerErrorTracker && agentError.agentId) {
+ providerErrorTracker.recordError(
+ sessionId,
+ agentError.agentId as ToolType,
+ {
+ type: agentError.type,
+ message: agentError.message,
+ recoverable: agentError.recoverable,
+ },
+ );
+ }
+
if (!accountDeps) return;
const accountRegistry = accountDeps.getAccountRegistry();
diff --git a/src/main/process-listeners/index.ts b/src/main/process-listeners/index.ts
index d2eaa7b94..cc5fb0ebb 100644
--- a/src/main/process-listeners/index.ts
+++ b/src/main/process-listeners/index.ts
@@ -45,12 +45,20 @@ export function setupProcessListeners(
// Session ID listener (with group chat participant/moderator storage)
setupSessionIdListener(processManager, deps);
- // Agent error listener (with optional account throttle/auth recovery handling)
+ // Agent error listener (with optional account throttle/auth recovery handling + provider failover)
+ const providerErrorTracker = deps.getProviderErrorTracker?.() ?? undefined;
setupErrorListener(processManager, deps, deps.getAccountRegistry ? {
getAccountRegistry: deps.getAccountRegistry,
getThrottleHandler: deps.getThrottleHandler ?? (() => null),
getAuthRecovery: deps.getAuthRecovery ?? (() => null),
- } : undefined);
+ } : undefined, providerErrorTracker);
+
+ // Reset provider error tracking on successful query completion
+ if (providerErrorTracker) {
+ processManager.on('query-complete', (sessionId: string) => {
+ providerErrorTracker.clearSession(sessionId);
+ });
+ }
// Stats/query-complete listener
setupStatsListener(processManager, deps);
diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts
index 7e2ae1356..c784ed79e 100644
--- a/src/main/process-listeners/types.ts
+++ b/src/main/process-listeners/types.ts
@@ -11,6 +11,7 @@ import type { StatsDB } from '../stats';
import type { AccountRegistry } from '../accounts/account-registry';
import type { AccountThrottleHandler } from '../accounts/account-throttle-handler';
import type { AccountAuthRecovery } from '../accounts/account-auth-recovery';
+import type { ProviderErrorTracker } from '../providers/provider-error-tracker';
import type { GroupChat, GroupChatParticipant } from '../group-chat/group-chat-storage';
import type { GroupChatMessage, GroupChatState } from '../../shared/group-chat-types';
import type { ParticipantState } from '../ipc/handlers/groupChat';
@@ -152,6 +153,8 @@ export interface ProcessListenerDependencies {
getThrottleHandler?: () => AccountThrottleHandler | null;
/** Account auth recovery getter (optional — only needed for account multiplexing) */
getAuthRecovery?: () => AccountAuthRecovery | null;
+ /** Provider error tracker (optional — only needed for Virtuosos provider failover) */
+ getProviderErrorTracker?: () => ProviderErrorTracker | null;
/** Debug log function */
debugLog: (prefix: string, message: string, ...args: unknown[]) => void;
/** Regex patterns */
diff --git a/src/main/providers/__tests__/provider-error-tracker.test.ts b/src/main/providers/__tests__/provider-error-tracker.test.ts
new file mode 100644
index 000000000..14900bb41
--- /dev/null
+++ b/src/main/providers/__tests__/provider-error-tracker.test.ts
@@ -0,0 +1,424 @@
+/**
+ * Tests for ProviderErrorTracker.
+ * Validates sliding window error tracking, failover suggestion logic,
+ * error type filtering, and session lifecycle management.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ProviderErrorTracker } from '../provider-error-tracker';
+import type { ProviderSwitchConfig, FailoverSuggestion } from '../../../shared/account-types';
+
+describe('ProviderErrorTracker', () => {
+ let tracker: ProviderErrorTracker;
+ let onFailoverSuggest: ReturnType;
+ const defaultConfig: ProviderSwitchConfig = {
+ enabled: true,
+ promptBeforeSwitch: true,
+ errorThreshold: 3,
+ errorWindowMs: 5 * 60 * 1000, // 5 minutes
+ fallbackProviders: ['claude-code', 'opencode', 'codex'],
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ onFailoverSuggest = vi.fn();
+ tracker = new ProviderErrorTracker(defaultConfig, onFailoverSuggest);
+ });
+
+ describe('recordError', () => {
+ it('should not record errors when disabled', () => {
+ const disabledTracker = new ProviderErrorTracker(
+ { ...defaultConfig, enabled: false },
+ onFailoverSuggest,
+ );
+
+ disabledTracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+
+ const stats = disabledTracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(0);
+ });
+
+ it('should only count recoverable errors', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: false, // Non-recoverable
+ });
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(0);
+ });
+
+ it('should only count failover-worthy error types', () => {
+ // token_exhaustion should not count
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'token_exhaustion',
+ message: 'Token limit reached',
+ recoverable: true,
+ });
+
+ // session_not_found should not count
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'session_not_found',
+ message: 'Session not found',
+ recoverable: true,
+ });
+
+ // permission_denied should not count
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'permission_denied',
+ message: 'Permission denied',
+ recoverable: true,
+ });
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(0);
+ });
+
+ it('should count rate_limited errors toward threshold', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(1);
+ });
+
+ it('should count network_error toward threshold', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'network_error',
+ message: 'Connection failed',
+ recoverable: true,
+ });
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(1);
+ });
+
+ it('should count agent_crashed toward threshold', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'agent_crashed',
+ message: 'Process exited',
+ recoverable: true,
+ });
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(1);
+ });
+
+ it('should count auth_expired toward threshold', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'auth_expired',
+ message: 'Auth expired',
+ recoverable: true,
+ });
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(1);
+ });
+ });
+
+ describe('failover suggestion', () => {
+ it('should emit failover suggestion when threshold is reached', () => {
+ for (let i = 0; i < 3; i++) {
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: `Rate limited ${i + 1}`,
+ recoverable: true,
+ });
+ }
+
+ expect(onFailoverSuggest).toHaveBeenCalledTimes(1);
+ const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0];
+ expect(suggestion.sessionId).toBe('session-1');
+ expect(suggestion.currentProvider).toBe('opencode');
+ expect(suggestion.suggestedProvider).toBe('claude-code'); // First in fallback list that isn't opencode
+ expect(suggestion.errorCount).toBe(3);
+ expect(suggestion.recentErrors).toHaveLength(3);
+ });
+
+ it('should not emit duplicate suggestions for the same session', () => {
+ for (let i = 0; i < 5; i++) {
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: `Rate limited ${i + 1}`,
+ recoverable: true,
+ });
+ }
+
+ // Should only be called once despite 5 errors
+ expect(onFailoverSuggest).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not emit suggestion below threshold', () => {
+ for (let i = 0; i < 2; i++) {
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: `Rate limited ${i + 1}`,
+ recoverable: true,
+ });
+ }
+
+ expect(onFailoverSuggest).not.toHaveBeenCalled();
+ });
+
+ it('should not emit suggestion when no fallback providers are available', () => {
+ const noFallbackTracker = new ProviderErrorTracker(
+ { ...defaultConfig, fallbackProviders: ['opencode'] },
+ onFailoverSuggest,
+ );
+
+ // All errors for opencode, but only opencode in fallback list
+ for (let i = 0; i < 3; i++) {
+ noFallbackTracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ }
+
+ expect(onFailoverSuggest).not.toHaveBeenCalled();
+ });
+
+ it('should pick first available fallback provider that differs from current', () => {
+ const tracker2 = new ProviderErrorTracker(
+ { ...defaultConfig, fallbackProviders: ['codex', 'opencode', 'claude-code'] },
+ onFailoverSuggest,
+ );
+
+ for (let i = 0; i < 3; i++) {
+ tracker2.recordError('session-1', 'codex', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ }
+
+ const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0];
+ expect(suggestion.suggestedProvider).toBe('opencode');
+ });
+ });
+
+ describe('clearSession', () => {
+ it('should reset error count and allow re-suggestion', () => {
+ // Hit threshold
+ for (let i = 0; i < 3; i++) {
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ }
+ expect(onFailoverSuggest).toHaveBeenCalledTimes(1);
+
+ // Clear session
+ tracker.clearSession('session-1');
+
+ const stats = tracker.getProviderStats('opencode');
+ expect(stats.activeErrorCount).toBe(0);
+
+ // Should be able to suggest again after clearing
+ for (let i = 0; i < 3; i++) {
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited again',
+ recoverable: true,
+ });
+ }
+ expect(onFailoverSuggest).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('removeSession', () => {
+ it('should completely remove session from tracking', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+
+ tracker.removeSession('session-1');
+
+ const stats = tracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(0);
+ expect(stats.sessionsWithErrors).toBe(0);
+ });
+ });
+
+ describe('getProviderStats', () => {
+ it('should aggregate stats across multiple sessions for the same provider', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ tracker.recordError('session-2', 'claude-code', {
+ type: 'network_error',
+ message: 'Network error',
+ recoverable: true,
+ });
+ tracker.recordError('session-3', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+
+ const claudeStats = tracker.getProviderStats('claude-code');
+ expect(claudeStats.activeErrorCount).toBe(2);
+ expect(claudeStats.sessionsWithErrors).toBe(2);
+
+ const opencodeStats = tracker.getProviderStats('opencode');
+ expect(opencodeStats.activeErrorCount).toBe(1);
+ expect(opencodeStats.sessionsWithErrors).toBe(1);
+ });
+
+ it('should return zero stats for providers with no errors', () => {
+ const stats = tracker.getProviderStats('codex');
+ expect(stats.activeErrorCount).toBe(0);
+ expect(stats.totalErrorsInWindow).toBe(0);
+ expect(stats.lastErrorAt).toBeNull();
+ expect(stats.sessionsWithErrors).toBe(0);
+ });
+ });
+
+ describe('getAllStats', () => {
+ it('should return stats for all tracked providers', () => {
+ tracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ tracker.recordError('session-2', 'opencode', {
+ type: 'network_error',
+ message: 'Network error',
+ recoverable: true,
+ });
+
+ const allStats = tracker.getAllStats();
+ expect(allStats.size).toBe(2);
+ expect(allStats.has('claude-code')).toBe(true);
+ expect(allStats.has('opencode')).toBe(true);
+ });
+ });
+
+ describe('updateConfig', () => {
+ it('should update the threshold dynamically', () => {
+ // With threshold 3, two errors should not trigger
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited 1',
+ recoverable: true,
+ });
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited 2',
+ recoverable: true,
+ });
+ expect(onFailoverSuggest).not.toHaveBeenCalled();
+
+ // Lower threshold to 2 — the session already has 2 errors
+ // Next error should trigger with the new threshold (need to hit new threshold)
+ tracker.updateConfig({ ...defaultConfig, errorThreshold: 2 });
+
+ // The existing 2 errors don't re-check — but the session already has 2 errors,
+ // and failoverSuggested is still false, so the next check should trigger
+ // Actually, errors are already recorded. Let's clear and re-record.
+ tracker.clearSession('session-1');
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited 1',
+ recoverable: true,
+ });
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited 2',
+ recoverable: true,
+ });
+
+ expect(onFailoverSuggest).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('sliding window', () => {
+ it('should prune errors older than the window', () => {
+ // Use a short window for testing
+ const shortWindowTracker = new ProviderErrorTracker(
+ { ...defaultConfig, errorWindowMs: 100 }, // 100ms window
+ onFailoverSuggest,
+ );
+
+ // Record 2 errors
+ shortWindowTracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited 1',
+ recoverable: true,
+ });
+ shortWindowTracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited 2',
+ recoverable: true,
+ });
+
+ // Wait for the window to expire
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ // Record another error — old ones should be pruned
+ shortWindowTracker.recordError('session-1', 'claude-code', {
+ type: 'rate_limited',
+ message: 'Rate limited 3',
+ recoverable: true,
+ });
+
+ // Should only have 1 error in window (the new one)
+ const stats = shortWindowTracker.getProviderStats('claude-code');
+ expect(stats.activeErrorCount).toBe(1);
+ // Should not have triggered failover (never had 3 in window)
+ expect(onFailoverSuggest).not.toHaveBeenCalled();
+ resolve();
+ }, 150);
+ });
+ });
+ });
+
+ describe('session name resolution', () => {
+ it('should use the provided session name resolver', () => {
+ const nameResolver = vi.fn().mockReturnValue('My Session');
+ const tracker2 = new ProviderErrorTracker(
+ defaultConfig,
+ onFailoverSuggest,
+ nameResolver,
+ );
+
+ for (let i = 0; i < 3; i++) {
+ tracker2.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ }
+
+ expect(nameResolver).toHaveBeenCalledWith('session-1');
+ const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0];
+ expect(suggestion.sessionName).toBe('My Session');
+ });
+
+ it('should fall back to session ID when no resolver is provided', () => {
+ for (let i = 0; i < 3; i++) {
+ tracker.recordError('session-1', 'opencode', {
+ type: 'rate_limited',
+ message: 'Rate limited',
+ recoverable: true,
+ });
+ }
+
+ const suggestion: FailoverSuggestion = onFailoverSuggest.mock.calls[0][0];
+ expect(suggestion.sessionName).toBe('session-1');
+ });
+ });
+});
diff --git a/src/main/providers/provider-error-tracker.ts b/src/main/providers/provider-error-tracker.ts
new file mode 100644
index 000000000..c1cff51f9
--- /dev/null
+++ b/src/main/providers/provider-error-tracker.ts
@@ -0,0 +1,214 @@
+/**
+ * ProviderErrorTracker
+ *
+ * Monitors consecutive agent errors per session in a sliding window.
+ * When errors exceed the configured threshold, emits a failover suggestion
+ * so the renderer can open SwitchProviderModal or auto-switch providers.
+ *
+ * Only counts recoverable, provider-level errors toward the threshold:
+ * - rate_limited, network_error, agent_crashed, auth_expired
+ *
+ * Does NOT count:
+ * - token_exhaustion (session issue, not provider)
+ * - session_not_found (transient)
+ * - permission_denied (non-recoverable, not provider instability)
+ * - unknown
+ */
+
+import type { ToolType, AgentErrorType } from '../../shared/types';
+import type {
+ ProviderSwitchConfig,
+ FailoverSuggestion,
+ ProviderErrorStats,
+} from '../../shared/account-types';
+import { logger } from '../utils/logger';
+
+const LOG_CONTEXT = 'ProviderErrorTracker';
+
+/** Error types that indicate provider instability and count toward failover */
+const FAILOVER_WORTHY_ERRORS: Set = new Set([
+ 'rate_limited',
+ 'network_error',
+ 'agent_crashed',
+ 'auth_expired',
+]);
+
+interface ErrorEvent {
+ timestamp: number;
+ errorType: AgentErrorType;
+ message: string;
+ recoverable: boolean;
+}
+
+interface SessionErrorState {
+ sessionId: string;
+ toolType: ToolType;
+ errors: ErrorEvent[];
+ failoverSuggested: boolean;
+}
+
+export class ProviderErrorTracker {
+ private sessions = new Map();
+ private config: ProviderSwitchConfig;
+ private onFailoverSuggest: (data: FailoverSuggestion) => void;
+ private sessionNameResolver: (sessionId: string) => string;
+
+ constructor(
+ config: ProviderSwitchConfig,
+ onFailoverSuggest: (data: FailoverSuggestion) => void,
+ sessionNameResolver?: (sessionId: string) => string,
+ ) {
+ this.config = config;
+ this.onFailoverSuggest = onFailoverSuggest;
+ this.sessionNameResolver = sessionNameResolver ?? ((id) => id);
+ }
+
+ /** Update config at runtime (when user changes settings) */
+ updateConfig(config: ProviderSwitchConfig): void {
+ this.config = config;
+ }
+
+ /** Record an error for a session */
+ recordError(sessionId: string, toolType: ToolType, error: {
+ type: AgentErrorType;
+ message: string;
+ recoverable: boolean;
+ }): void {
+ if (!this.config.enabled) return;
+
+ // Only count recoverable, failover-worthy errors
+ if (!error.recoverable || !FAILOVER_WORTHY_ERRORS.has(error.type)) {
+ return;
+ }
+
+ // Get or create session error state
+ let state = this.sessions.get(sessionId);
+ if (!state) {
+ state = {
+ sessionId,
+ toolType,
+ errors: [],
+ failoverSuggested: false,
+ };
+ this.sessions.set(sessionId, state);
+ }
+
+ const now = Date.now();
+
+ // Add the error
+ state.errors.push({
+ timestamp: now,
+ errorType: error.type,
+ message: error.message,
+ recoverable: error.recoverable,
+ });
+
+ // Prune errors older than the window
+ const windowStart = now - this.config.errorWindowMs;
+ state.errors = state.errors.filter(e => e.timestamp >= windowStart);
+
+ // Check threshold
+ const errorCount = state.errors.length;
+ if (errorCount >= this.config.errorThreshold && !state.failoverSuggested) {
+ state.failoverSuggested = true;
+
+ // Determine target provider from fallback list
+ const suggestedProvider = this.config.fallbackProviders.find(p => p !== toolType);
+ if (!suggestedProvider) {
+ logger.warn('No fallback provider available for failover', LOG_CONTEXT, {
+ sessionId,
+ toolType,
+ errorCount,
+ });
+ return;
+ }
+
+ const suggestion: FailoverSuggestion = {
+ sessionId,
+ sessionName: this.sessionNameResolver(sessionId),
+ currentProvider: toolType,
+ suggestedProvider,
+ errorCount,
+ windowMs: this.config.errorWindowMs,
+ recentErrors: state.errors.map(e => ({
+ type: e.errorType,
+ message: e.message,
+ timestamp: e.timestamp,
+ })),
+ };
+
+ logger.info('Failover threshold reached, suggesting switch', LOG_CONTEXT, {
+ sessionId,
+ currentProvider: toolType,
+ suggestedProvider,
+ errorCount,
+ threshold: this.config.errorThreshold,
+ });
+
+ this.onFailoverSuggest(suggestion);
+ }
+ }
+
+ /** Clear errors for a session (e.g., after successful response) */
+ clearSession(sessionId: string): void {
+ const state = this.sessions.get(sessionId);
+ if (state) {
+ state.errors = [];
+ state.failoverSuggested = false;
+ }
+ }
+
+ /** Remove a session entirely (on close) */
+ removeSession(sessionId: string): void {
+ this.sessions.delete(sessionId);
+ }
+
+ /** Get error stats for a provider type (for health dashboard) */
+ getProviderStats(toolType: ToolType): ProviderErrorStats {
+ const now = Date.now();
+ const windowStart = now - this.config.errorWindowMs;
+
+ let activeErrorCount = 0;
+ let totalErrorsInWindow = 0;
+ let lastErrorAt: number | null = null;
+ let sessionsWithErrors = 0;
+
+ for (const state of this.sessions.values()) {
+ if (state.toolType !== toolType) continue;
+
+ // Prune stale errors
+ const active = state.errors.filter(e => e.timestamp >= windowStart);
+ if (active.length > 0) {
+ sessionsWithErrors++;
+ totalErrorsInWindow += active.length;
+ activeErrorCount += active.length;
+ const latest = active[active.length - 1].timestamp;
+ if (lastErrorAt === null || latest > lastErrorAt) {
+ lastErrorAt = latest;
+ }
+ }
+ }
+
+ return {
+ toolType,
+ activeErrorCount,
+ totalErrorsInWindow,
+ lastErrorAt,
+ sessionsWithErrors,
+ };
+ }
+
+ /** Get all provider stats */
+ getAllStats(): Map {
+ const toolTypes = new Set();
+ for (const state of this.sessions.values()) {
+ toolTypes.add(state.toolType);
+ }
+
+ const result = new Map();
+ for (const toolType of toolTypes) {
+ result.set(toolType, this.getProviderStats(toolType));
+ }
+ return result;
+ }
+}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index e45a77bdc..589182896 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1882,6 +1882,46 @@ function MaestroConsoleInner() {
};
}, [encoreFeatures.virtuosos]);
+ // Subscribe to provider failover suggestion events (Virtuosos provider switching)
+ useEffect(() => {
+ if (!encoreFeatures.virtuosos) return;
+
+ const cleanup = window.maestro.providers.onFailoverSuggest(async (suggestion) => {
+ // Find the session
+ const session = sessionsRef.current.find(s => s.id === suggestion.sessionId);
+ if (!session) return;
+
+ // Load provider switch config from settings
+ let providerConfig: { promptBeforeSwitch?: boolean } = { promptBeforeSwitch: true };
+ try {
+ const saved = await window.maestro.settings.get('providerSwitchConfig');
+ if (saved && typeof saved === 'object') {
+ providerConfig = saved as typeof providerConfig;
+ }
+ } catch {
+ // Use default
+ }
+
+ // Show toast notification
+ notifyToast({
+ type: 'warning',
+ title: 'Provider Issues Detected',
+ message: `${getAgentDisplayName(suggestion.currentProvider as ToolType)} had ${suggestion.errorCount} errors. ${
+ providerConfig.promptBeforeSwitch !== false
+ ? 'Suggesting switch...'
+ : `Auto-switching to ${getAgentDisplayName(suggestion.suggestedProvider as ToolType)}`
+ }`,
+ duration: 8_000,
+ });
+
+ // Always open SwitchProviderModal for manual confirmation
+ // (auto-switch path will be invoked once handleConfirmProviderSwitch is available)
+ setSwitchProviderSession(session);
+ });
+
+ return cleanup;
+ }, [encoreFeatures.virtuosos]);
+
// Keyboard navigation state
// Note: selectedSidebarIndex/setSelectedSidebarIndex are destructured from useUIStore() above
// Note: activeTab is memoized later at line ~3795 - use that for all tab operations
@@ -4942,6 +4982,9 @@ You are taking over this conversation. Based on the context above, provide a bri
));
}
+ // Clear provider error tracking for source session
+ window.maestro.providers.clearSessionErrors(switchProviderSession.id).catch(() => {});
+
// Navigate to the new session
setActiveSessionId(result.newSessionId!);
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 259b85f7c..2d69d9afd 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2761,6 +2761,38 @@ interface MaestroAPI {
}>;
};
+ // Provider Error Tracking API (error stats, failover suggestions)
+ providers: {
+ getErrorStats: (toolType: string) => Promise<{
+ toolType: string;
+ activeErrorCount: number;
+ totalErrorsInWindow: number;
+ lastErrorAt: number | null;
+ sessionsWithErrors: number;
+ } | null>;
+ getAllErrorStats: () => Promise>;
+ clearSessionErrors: (sessionId: string) => Promise;
+ onFailoverSuggest: (handler: (data: {
+ sessionId: string;
+ sessionName: string;
+ currentProvider: string;
+ suggestedProvider: string;
+ errorCount: number;
+ windowMs: number;
+ recentErrors: Array<{
+ type: string;
+ message: string;
+ timestamp: number;
+ }>;
+ }) => void) => () => void;
+ };
+
// WakaTime API (CLI check, API key validation)
wakatime: {
checkCli: () => Promise<{ available: boolean; version?: string }>;
diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts
index 7da7d1d04..9d58d0927 100644
--- a/src/shared/account-types.ts
+++ b/src/shared/account-types.ts
@@ -129,7 +129,7 @@ export const ACCOUNT_SWITCH_DEFAULTS: AccountSwitchConfig = {
/** Default token window: 5 hours in milliseconds */
export const DEFAULT_TOKEN_WINDOW_MS = 5 * 60 * 60 * 1000;
-import type { ToolType } from './types';
+import type { ToolType, AgentErrorType } from './types';
/**
* Configuration for automated provider failover (Virtuosos vertical swapping).
@@ -155,3 +155,33 @@ export const DEFAULT_PROVIDER_SWITCH_CONFIG: ProviderSwitchConfig = {
errorWindowMs: 5 * 60 * 1000, // 5 minutes
fallbackProviders: [],
};
+
+/**
+ * Failover suggestion emitted when a provider exceeds the error threshold.
+ * Sent from main process to renderer via IPC to trigger SwitchProviderModal or auto-switch.
+ */
+export interface FailoverSuggestion {
+ sessionId: string;
+ sessionName: string;
+ currentProvider: ToolType;
+ suggestedProvider: ToolType;
+ errorCount: number;
+ windowMs: number;
+ recentErrors: Array<{
+ type: AgentErrorType;
+ message: string;
+ timestamp: number;
+ }>;
+}
+
+/**
+ * Error statistics for a single provider type.
+ * Used by the ProviderPanel health dashboard.
+ */
+export interface ProviderErrorStats {
+ toolType: ToolType;
+ activeErrorCount: number;
+ totalErrorsInWindow: number;
+ lastErrorAt: number | null;
+ sessionsWithErrors: number;
+}
From c2a8bd571cc6cfa64a01e46ce5dbaef0dfaa76d7 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 17:50:05 -0500
Subject: [PATCH 43/59] MAESTRO: add Provider Health Dashboard with live error
monitoring and health cards
Replace the basic Provider Status Grid in ProviderPanel with an enriched
health dashboard showing per-provider health cards with status badges,
error stats, health bars, and auto-refresh. Add useProviderHealth hook
for live data with 10s polling and immediate failover event updates.
Add health badge indicator on the Providers tab in VirtuososModal.
Co-Authored-By: Claude Opus 4.6
---
.../renderer/hooks/useProviderHealth.test.ts | 313 ++++++++++++++++++
src/__tests__/setup.ts | 6 +
.../components/ProviderHealthCard.tsx | 300 +++++++++++++++++
src/renderer/components/ProviderPanel.tsx | 202 +++++------
src/renderer/components/VirtuososModal.tsx | 12 +
src/renderer/hooks/useProviderHealth.ts | 178 ++++++++++
6 files changed, 917 insertions(+), 94 deletions(-)
create mode 100644 src/__tests__/renderer/hooks/useProviderHealth.test.ts
create mode 100644 src/renderer/components/ProviderHealthCard.tsx
create mode 100644 src/renderer/hooks/useProviderHealth.ts
diff --git a/src/__tests__/renderer/hooks/useProviderHealth.test.ts b/src/__tests__/renderer/hooks/useProviderHealth.test.ts
new file mode 100644
index 000000000..4265f678b
--- /dev/null
+++ b/src/__tests__/renderer/hooks/useProviderHealth.test.ts
@@ -0,0 +1,313 @@
+/**
+ * Tests for useProviderHealth hook.
+ * Validates health computation, status determination, and failover event subscription.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useProviderHealth } from '../../../renderer/hooks/useProviderHealth';
+import type { Session } from '../../../renderer/types';
+import type { ProviderErrorStats } from '../../../shared/account-types';
+
+// ── Mock data ────────────────────────────────────────────────────────────────
+
+const mockAgents = [
+ { id: 'claude-code', name: 'Claude Code', available: true, hidden: false },
+ { id: 'opencode', name: 'OpenCode', available: true, hidden: false },
+ { id: 'codex', name: 'Codex', available: false, hidden: false },
+ { id: 'terminal', name: 'Terminal', available: true, hidden: false },
+];
+
+const emptyErrorStats: Record = {};
+
+function createSession(id: string, toolType: string, overrides?: Partial): Session {
+ return {
+ id,
+ name: `Session ${id}`,
+ toolType: toolType as any,
+ state: 'idle',
+ cwd: '/test',
+ fullPath: '/test',
+ projectRoot: '/test',
+ aiLogs: [],
+ shellLogs: [],
+ workLog: [],
+ contextUsage: 0,
+ inputMode: 'ai',
+ aiPid: 0,
+ terminalPid: 0,
+ port: 0,
+ isLive: false,
+ changedFiles: [],
+ isGitRepo: false,
+ fileTree: [],
+ fileExplorerExpanded: [],
+ fileExplorerScrollPos: 0,
+ activeTimeMs: 0,
+ executionQueue: [],
+ aiTabs: [],
+ activeTabId: '',
+ closedTabHistory: [],
+ ...overrides,
+ };
+}
+
+// ── Mocks ──────────────────────────────────────────────────────────────────
+
+let failoverCallback: (() => void) | null = null;
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ failoverCallback = null;
+
+ vi.mocked(window.maestro.agents.detect).mockResolvedValue(mockAgents);
+ vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(emptyErrorStats);
+ vi.mocked(window.maestro.settings.get).mockResolvedValue(null);
+ vi.mocked(window.maestro.providers.onFailoverSuggest).mockImplementation((handler: any) => {
+ failoverCallback = handler;
+ return () => { failoverCallback = null; };
+ });
+});
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe('useProviderHealth', () => {
+ it('loads providers on mount and sets loading state', async () => {
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ expect(result.current.isLoading).toBe(true);
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Should filter out terminal
+ expect(result.current.providers).toHaveLength(3);
+ expect(result.current.providers.map((p) => p.toolType)).toEqual([
+ 'claude-code',
+ 'opencode',
+ 'codex',
+ ]);
+ });
+
+ it('computes healthy status for available providers with 0 errors', async () => {
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!;
+ expect(claude.status).toBe('healthy');
+ expect(claude.healthPercent).toBe(100);
+ expect(claude.activeSessionCount).toBe(1);
+ });
+
+ it('computes not_installed status for unavailable providers', async () => {
+ const sessions: Session[] = [];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const codex = result.current.providers.find((p) => p.toolType === 'codex')!;
+ expect(codex.status).toBe('not_installed');
+ expect(codex.healthPercent).toBe(0);
+ });
+
+ it('computes idle status for available providers with 0 sessions', async () => {
+ const sessions: Session[] = [];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const opencode = result.current.providers.find((p) => p.toolType === 'opencode')!;
+ expect(opencode.status).toBe('idle');
+ expect(opencode.healthPercent).toBe(100);
+ });
+
+ it('computes degraded status when errors exist below threshold', async () => {
+ const errorStats: Record = {
+ 'claude-code': {
+ toolType: 'claude-code',
+ activeErrorCount: 1,
+ totalErrorsInWindow: 1,
+ lastErrorAt: Date.now() - 30_000,
+ sessionsWithErrors: 1,
+ },
+ };
+ vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats);
+
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!;
+ expect(claude.status).toBe('degraded');
+ expect(claude.healthPercent).toBe(67); // 100 - (1/3)*100 = 67
+ expect(result.current.hasDegradedProvider).toBe(true);
+ expect(result.current.hasFailingProvider).toBe(false);
+ });
+
+ it('computes failing status when errors meet threshold', async () => {
+ const errorStats: Record = {
+ 'claude-code': {
+ toolType: 'claude-code',
+ activeErrorCount: 3,
+ totalErrorsInWindow: 3,
+ lastErrorAt: Date.now() - 10_000,
+ sessionsWithErrors: 1,
+ },
+ };
+ vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats);
+
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!;
+ expect(claude.status).toBe('failing');
+ expect(claude.healthPercent).toBe(0);
+ expect(result.current.hasFailingProvider).toBe(true);
+ });
+
+ it('counts active sessions excluding archived migrations', async () => {
+ const sessions = [
+ createSession('s1', 'claude-code'),
+ createSession('s2', 'claude-code'),
+ createSession('s3', 'claude-code', { archivedByMigration: true }),
+ ];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!;
+ expect(claude.activeSessionCount).toBe(2);
+ });
+
+ it('reads failover threshold from saved config', async () => {
+ vi.mocked(window.maestro.settings.get).mockResolvedValue({
+ errorThreshold: 5,
+ });
+
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.failoverThreshold).toBe(5);
+ });
+
+ it('subscribes to failover suggest events', async () => {
+ const sessions = [createSession('s1', 'claude-code')];
+ renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(window.maestro.providers.onFailoverSuggest).toHaveBeenCalled();
+ });
+ });
+
+ it('refreshes on failover suggest event', async () => {
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ // Change error stats
+ const errorStats: Record = {
+ 'claude-code': {
+ toolType: 'claude-code',
+ activeErrorCount: 2,
+ totalErrorsInWindow: 2,
+ lastErrorAt: Date.now(),
+ sessionsWithErrors: 1,
+ },
+ };
+ vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats);
+
+ // Simulate failover event
+ await act(async () => {
+ failoverCallback?.();
+ });
+
+ await waitFor(() => {
+ const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!;
+ expect(claude.status).toBe('degraded');
+ });
+ });
+
+ it('sets lastUpdated timestamp after refresh', async () => {
+ const sessions: Session[] = [];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ expect(result.current.lastUpdated).toBeTypeOf('number');
+ expect(result.current.lastUpdated!).toBeGreaterThan(0);
+ });
+
+ it('provides a manual refresh function', async () => {
+ const sessions: Session[] = [];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const callCount = vi.mocked(window.maestro.agents.detect).mock.calls.length;
+
+ await act(async () => {
+ result.current.refresh();
+ });
+
+ expect(vi.mocked(window.maestro.agents.detect).mock.calls.length).toBeGreaterThan(callCount);
+ });
+
+ it('computes health percent correctly with custom threshold', async () => {
+ vi.mocked(window.maestro.settings.get).mockResolvedValue({
+ errorThreshold: 10,
+ });
+
+ const errorStats: Record = {
+ 'claude-code': {
+ toolType: 'claude-code',
+ activeErrorCount: 3,
+ totalErrorsInWindow: 3,
+ lastErrorAt: Date.now(),
+ sessionsWithErrors: 1,
+ },
+ };
+ vi.mocked(window.maestro.providers.getAllErrorStats).mockResolvedValue(errorStats);
+
+ const sessions = [createSession('s1', 'claude-code')];
+ const { result } = renderHook(() => useProviderHealth(sessions, 600_000));
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ const claude = result.current.providers.find((p) => p.toolType === 'claude-code')!;
+ // With threshold 10: 100 - (3/10)*100 = 70
+ expect(claude.healthPercent).toBe(70);
+ expect(claude.status).toBe('degraded');
+ });
+});
diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts
index b45511e6d..5f9d7835c 100644
--- a/src/__tests__/setup.ts
+++ b/src/__tests__/setup.ts
@@ -577,6 +577,12 @@ const mockMaestro = {
onRecoveryAvailable: vi.fn().mockReturnValue(() => {}),
checkRecovery: vi.fn().mockResolvedValue({ recovered: [] }),
},
+ providers: {
+ getErrorStats: vi.fn().mockResolvedValue(null),
+ getAllErrorStats: vi.fn().mockResolvedValue({}),
+ clearSessionErrors: vi.fn().mockResolvedValue(undefined),
+ onFailoverSuggest: vi.fn().mockReturnValue(() => {}),
+ },
app: {
onQuitConfirmationRequest: vi.fn().mockReturnValue(() => {}),
confirmQuit: vi.fn(),
diff --git a/src/renderer/components/ProviderHealthCard.tsx b/src/renderer/components/ProviderHealthCard.tsx
new file mode 100644
index 000000000..a4c1fceb3
--- /dev/null
+++ b/src/renderer/components/ProviderHealthCard.tsx
@@ -0,0 +1,300 @@
+/**
+ * ProviderHealthCard - Individual provider health status card
+ *
+ * Displays:
+ * - Provider icon and name
+ * - Health status badge (Healthy/Degraded/Failing/Not Installed/Idle)
+ * - Stats grid: sessions, errors, error rate, last error
+ * - Health bar at bottom (green/yellow/red gradient)
+ */
+
+import React from 'react';
+import type { Theme } from '../types';
+import type { ToolType } from '../../shared/types';
+import type { ProviderErrorStats } from '../../shared/account-types';
+import { getAgentIcon } from '../constants/agentIcons';
+import { getAgentDisplayName } from '../services/contextGroomer';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type HealthStatus = 'healthy' | 'degraded' | 'failing' | 'not_installed' | 'idle';
+
+export interface ProviderHealthCardProps {
+ theme: Theme;
+ toolType: ToolType;
+ available: boolean;
+ activeSessionCount: number;
+ errorStats: ProviderErrorStats | null;
+ failoverThreshold: number;
+ healthPercent: number;
+ status: HealthStatus;
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function getStatusLabel(status: HealthStatus, errorCount: number): string {
+ switch (status) {
+ case 'healthy':
+ return 'Healthy';
+ case 'degraded':
+ return `Degraded (${errorCount} error${errorCount !== 1 ? 's' : ''})`;
+ case 'failing':
+ return 'Failing';
+ case 'not_installed':
+ return 'Not Installed';
+ case 'idle':
+ return 'No Sessions';
+ }
+}
+
+function getStatusColor(status: HealthStatus, theme: Theme): string {
+ switch (status) {
+ case 'healthy':
+ return theme.colors.success;
+ case 'degraded':
+ return theme.colors.warning;
+ case 'failing':
+ return theme.colors.error;
+ case 'not_installed':
+ return theme.colors.textDim;
+ case 'idle':
+ return theme.colors.accent;
+ }
+}
+
+function getStatusBgTint(status: HealthStatus, theme: Theme): string {
+ switch (status) {
+ case 'healthy':
+ return theme.colors.success + '08';
+ case 'degraded':
+ return theme.colors.warning + '08';
+ case 'failing':
+ return theme.colors.error + '08';
+ default:
+ return 'transparent';
+ }
+}
+
+function getHealthBarColor(healthPercent: number, theme: Theme): string {
+ if (healthPercent >= 80) return theme.colors.success;
+ if (healthPercent >= 50) return theme.colors.warning;
+ return theme.colors.error;
+}
+
+function formatRelativeTime(timestamp: number | null): string {
+ if (!timestamp) return '\u2014';
+ const diffMs = Date.now() - timestamp;
+ if (diffMs < 0) return 'just now';
+ const seconds = Math.floor(diffMs / 1000);
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h ago`;
+}
+
+function formatWindowDuration(ms: number): string {
+ const minutes = Math.round(ms / 60000);
+ return `${minutes}m`;
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+export function ProviderHealthCard({
+ theme,
+ toolType,
+ available,
+ activeSessionCount,
+ errorStats,
+ failoverThreshold,
+ healthPercent,
+ status,
+}: ProviderHealthCardProps) {
+ const errorCount = errorStats?.totalErrorsInWindow ?? 0;
+ const windowMs = 5 * 60 * 1000; // Default 5m window display
+ const statusColor = getStatusColor(status, theme);
+ const bgTint = getStatusBgTint(status, theme);
+ const barColor = status === 'not_installed'
+ ? theme.colors.textDim + '30'
+ : getHealthBarColor(healthPercent, theme);
+
+ // Approximate error rate: errors / (errors + some baseline)
+ // Since we don't have total responses, show raw error count and threshold fraction
+ const errorRateDisplay = status === 'not_installed' || status === 'idle'
+ ? '\u2014'
+ : errorCount === 0
+ ? '0%'
+ : `${Math.min(100, Math.round((errorCount / failoverThreshold) * 100))}%`;
+
+ return (
+ {
+ e.currentTarget.style.boxShadow = `0 2px 8px ${theme.colors.border}80`;
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.boxShadow = 'none';
+ }}
+ >
+ {/* Header: icon + name */}
+
+ {getAgentIcon(toolType)}
+
+ {getAgentDisplayName(toolType)}
+
+
+
+ {/* Status badge */}
+
+
+
+ {getStatusLabel(status, errorCount)}
+
+
+
+ {/* Stats grid */}
+
+
+
+
+
+
+
+ {/* Health bar */}
+
+
+
+ {status === 'not_installed' ? 'N/A' : `${Math.round(healthPercent)}%`}
+
+
+
+ );
+}
+
+// ============================================================================
+// Sub-components
+// ============================================================================
+
+function StatRow({
+ theme,
+ label,
+ value,
+}: {
+ theme: Theme;
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+ {label}:
+
+
+ {value}
+
+
+ );
+}
+
+export default ProviderHealthCard;
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
index 64fc6ada4..65dedc6a0 100644
--- a/src/renderer/components/ProviderPanel.tsx
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -14,13 +14,16 @@ import {
Plus,
X,
ArrowRightLeft,
+ RefreshCw,
} from 'lucide-react';
-import type { Theme, Session, AgentConfig } from '../types';
+import type { Theme, Session } from '../types';
import type { ToolType } from '../../shared/types';
import type { ProviderSwitchConfig } from '../../shared/account-types';
import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
import { getAgentIcon } from '../constants/agentIcons';
import { getAgentDisplayName } from '../services/contextGroomer';
+import { ProviderHealthCard } from './ProviderHealthCard';
+import { useProviderHealth } from '../hooks/useProviderHealth';
// ============================================================================
// Types
@@ -31,14 +34,6 @@ interface ProviderPanelProps {
sessions?: Session[];
}
-interface ProviderStatus {
- id: ToolType;
- name: string;
- icon: string;
- available: boolean;
- activeSessionCount: number;
-}
-
interface MigrationEntry {
timestamp: number;
sessionName: string;
@@ -97,38 +92,16 @@ function ordinalSuffix(n: number): string {
// ============================================================================
export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
- const [providers, setProviders] = useState([]);
+ const {
+ providers: healthProviders,
+ isLoading: healthLoading,
+ lastUpdated,
+ refresh: refreshHealth,
+ failoverThreshold,
+ } = useProviderHealth(sessions);
const [config, setConfig] = useState(DEFAULT_PROVIDER_SWITCH_CONFIG);
const [showMoreHistory, setShowMoreHistory] = useState(false);
- // ── Load providers ──────────────────────────────────────────────────
- useEffect(() => {
- async function detectProviders() {
- try {
- const agents: AgentConfig[] = await window.maestro.agents.detect();
- const statuses: ProviderStatus[] = agents
- .filter((a) => a.id !== 'terminal' && !a.hidden)
- .map((agent) => {
- const toolType = agent.id as ToolType;
- const activeCount = sessions.filter(
- (s) => s.toolType === toolType && !s.archivedByMigration
- ).length;
- return {
- id: toolType,
- name: getAgentDisplayName(toolType),
- icon: getAgentIcon(toolType),
- available: agent.available,
- activeSessionCount: activeCount,
- };
- });
- setProviders(statuses);
- } catch (err) {
- console.error('Failed to detect agents:', err);
- }
- }
- detectProviders();
- }, [sessions]);
-
// ── Load failover config ────────────────────────────────────────────
useEffect(() => {
async function loadConfig() {
@@ -184,9 +157,9 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
const hasMoreMigrations = migrations.length > MIGRATION_HISTORY_LIMIT;
// ── Fallback provider management ────────────────────────────────────
- const availableForFallback = providers.filter(
- (p) => !config.fallbackProviders.includes(p.id)
- );
+ const availableForFallback = healthProviders
+ .filter((p) => !config.fallbackProviders.includes(p.toolType))
+ .map((p) => ({ id: p.toolType, name: p.displayName, icon: getAgentIcon(p.toolType), available: p.available }));
const handleAddFallback = (toolType: ToolType) => {
saveConfig({ fallbackProviders: [...config.fallbackProviders, toolType] });
@@ -234,65 +207,106 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
// ── Render ───────────────────────────────────────────────────────────
return (
- {/* Provider Status Grid */}
+ {/* Provider Health Dashboard */}
-
Provider Status
-
- {providers.map((provider) => (
-
+
Provider Health
+
+
+ Auto-refresh: every 10s
+
+
-
- {provider.icon}
-
+ Refresh
+
+
+
+
+ {healthLoading && healthProviders.length === 0 ? (
+
+ {[0, 1].map((i) => (
+
+
- {provider.name}
-
-
-
-
-
- {provider.available ? 'Available' : 'Not Installed'}
-
-
- {provider.activeSessionCount} active{' '}
- {provider.activeSessionCount === 1 ? 'session' : 'sessions'}
-
-
0 errors (5m)
+ />
+
+
-
- ))}
- {providers.length === 0 && (
-
No providers detected
- )}
-
+ ))}
+
+ ) : (
+
+ {healthProviders.map((provider) => (
+
+ ))}
+ {healthProviders.length === 0 && (
+
No providers detected
+ )}
+
+ )}
{/* Failover Configuration */}
@@ -591,7 +605,7 @@ function AddProviderDropdown({
onAdd,
}: {
theme: Theme;
- providers: ProviderStatus[];
+ providers: { id: ToolType; name: string; icon: string; available: boolean }[];
onAdd: (toolType: ToolType) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
index a74ee7ef5..295eb266c 100644
--- a/src/renderer/components/VirtuososModal.tsx
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -14,6 +14,7 @@ import { ProviderPanel } from './ProviderPanel';
import { VirtuosoUsageView } from './VirtuosoUsageView';
import { Modal } from './ui/Modal';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
+import { useProviderHealth } from '../hooks/useProviderHealth';
import type { Theme, Session } from '../types';
type VirtuosoTab = 'config' | 'providers' | 'usage';
@@ -33,6 +34,7 @@ interface VirtuososModalProps {
export function VirtuososModal({ isOpen, onClose, theme, sessions }: VirtuososModalProps) {
const [activeTab, setActiveTab] = useState
('config');
+ const { hasDegradedProvider, hasFailingProvider } = useProviderHealth(sessions);
// Keyboard navigation: Cmd/Ctrl+Shift+[ and Cmd/Ctrl+Shift+]
useEffect(() => {
@@ -120,6 +122,16 @@ export function VirtuososModal({ isOpen, onClose, theme, sessions }: VirtuososMo
>
{tab.label}
+ {tab.value === 'providers' && hasDegradedProvider && (
+
+ )}
);
})}
diff --git a/src/renderer/hooks/useProviderHealth.ts b/src/renderer/hooks/useProviderHealth.ts
new file mode 100644
index 000000000..a28e3d117
--- /dev/null
+++ b/src/renderer/hooks/useProviderHealth.ts
@@ -0,0 +1,178 @@
+/**
+ * useProviderHealth - Live provider health data with auto-refresh
+ *
+ * Combines agent detection, error stats, and session counts into
+ * per-provider health data. Polls on an interval and refreshes
+ * immediately on failover suggestions.
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import type { Session, AgentConfig } from '../types';
+import type { ToolType } from '../../shared/types';
+import type { ProviderErrorStats, ProviderSwitchConfig } from '../../shared/account-types';
+import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
+import { getAgentDisplayName } from '../services/contextGroomer';
+import type { HealthStatus } from '../components/ProviderHealthCard';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ProviderHealth {
+ toolType: ToolType;
+ available: boolean;
+ displayName: string;
+ activeSessionCount: number;
+ errorStats: ProviderErrorStats | null;
+ healthPercent: number;
+ status: HealthStatus;
+}
+
+export interface UseProviderHealthResult {
+ providers: ProviderHealth[];
+ isLoading: boolean;
+ lastUpdated: number | null;
+ refresh: () => void;
+ failoverThreshold: number;
+ hasDegradedProvider: boolean;
+ hasFailingProvider: boolean;
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function computeHealthPercent(
+ available: boolean,
+ activeSessionCount: number,
+ errorCount: number,
+ threshold: number,
+): number {
+ if (!available) return 0;
+ if (activeSessionCount === 0) return 100;
+ if (errorCount === 0) return 100;
+ return Math.max(0, Math.round(100 - (errorCount / threshold) * 100));
+}
+
+function computeStatus(
+ available: boolean,
+ activeSessionCount: number,
+ errorCount: number,
+ threshold: number,
+): HealthStatus {
+ if (!available) return 'not_installed';
+ if (activeSessionCount === 0) return 'idle';
+ if (errorCount === 0) return 'healthy';
+ if (errorCount >= threshold) return 'failing';
+ return 'degraded';
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+const DEFAULT_REFRESH_INTERVAL = 10_000;
+
+export function useProviderHealth(
+ sessions: Session[] | undefined,
+ refreshIntervalMs: number = DEFAULT_REFRESH_INTERVAL,
+): UseProviderHealthResult {
+ const [providers, setProviders] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [failoverThreshold, setFailoverThreshold] = useState(
+ DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold,
+ );
+ const intervalRef = useRef | null>(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ // Fetch availability, error stats, and failover config in parallel
+ const [agents, errorStatsRecord, savedConfig] = await Promise.all([
+ window.maestro.agents.detect() as Promise,
+ window.maestro.providers.getAllErrorStats() as Promise>,
+ window.maestro.settings.get('providerSwitchConfig') as Promise | null>,
+ ]);
+
+ const threshold = (savedConfig as Partial)?.errorThreshold
+ ?? DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold;
+ setFailoverThreshold(threshold);
+
+ const sessionList = sessions ?? [];
+
+ const healthData: ProviderHealth[] = agents
+ .filter((a) => a.id !== 'terminal' && !a.hidden)
+ .map((agent) => {
+ const toolType = agent.id as ToolType;
+ const activeCount = sessionList.filter(
+ (s) => s.toolType === toolType && !s.archivedByMigration,
+ ).length;
+ const stats = errorStatsRecord[toolType] ?? null;
+ const errorCount = stats?.totalErrorsInWindow ?? 0;
+
+ const healthPercent = computeHealthPercent(
+ agent.available,
+ activeCount,
+ errorCount,
+ threshold,
+ );
+ const status = computeStatus(
+ agent.available,
+ activeCount,
+ errorCount,
+ threshold,
+ );
+
+ return {
+ toolType,
+ available: agent.available,
+ displayName: getAgentDisplayName(toolType),
+ activeSessionCount: activeCount,
+ errorStats: stats,
+ healthPercent,
+ status,
+ };
+ });
+
+ setProviders(healthData);
+ setLastUpdated(Date.now());
+ setIsLoading(false);
+ } catch (err) {
+ console.warn('[useProviderHealth] Failed to refresh:', err);
+ setIsLoading(false);
+ }
+ }, [sessions]);
+
+ // Initial fetch + polling interval
+ useEffect(() => {
+ refresh();
+
+ intervalRef.current = setInterval(refresh, refreshIntervalMs);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [refresh, refreshIntervalMs]);
+
+ // Subscribe to failover suggestions for immediate refresh (Task 4)
+ useEffect(() => {
+ const cleanup = window.maestro.providers?.onFailoverSuggest?.(() => {
+ refresh();
+ });
+ return cleanup;
+ }, [refresh]);
+
+ const hasDegradedProvider = providers.some(
+ (p) => p.status === 'degraded' || p.status === 'failing',
+ );
+ const hasFailingProvider = providers.some((p) => p.status === 'failing');
+
+ return {
+ providers,
+ isLoading,
+ lastUpdated,
+ refresh,
+ failoverThreshold,
+ hasDegradedProvider,
+ hasFailingProvider,
+ };
+}
From 50360d8342d8d09044cdbf8742d4db1646c98650 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 18:32:11 -0500
Subject: [PATCH 44/59] MAESTRO: add merge-back mode for provider switching
with provenance chain walking
Implements findArchivedPredecessor() to walk the migration provenance chain
backwards and find archived sessions matching a target provider. Adds merge-back
orchestration to useProviderSwitch that reactivates archived sessions instead of
creating new ones during round-trip provider switches. Includes 13 tests covering
chain walking, cycle detection, identity preservation, and context log appending.
Co-Authored-By: Claude Opus 4.6
---
.../__tests__/provider-error-tracker.test.ts | 2 +-
src/renderer/App.tsx | 25 +-
src/renderer/components/ProviderPanel.tsx | 44 +-
src/renderer/components/SessionItem.tsx | 12 +-
.../components/SwitchProviderModal.tsx | 133 +++++-
.../agent/__tests__/useProviderSwitch.test.ts | 389 ++++++++++++++++++
src/renderer/hooks/agent/useProviderSwitch.ts | 182 ++++++--
src/renderer/types/index.ts | 2 +
src/shared/account-types.ts | 6 +
9 files changed, 741 insertions(+), 54 deletions(-)
create mode 100644 src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts
diff --git a/src/main/providers/__tests__/provider-error-tracker.test.ts b/src/main/providers/__tests__/provider-error-tracker.test.ts
index 14900bb41..f4276d2c2 100644
--- a/src/main/providers/__tests__/provider-error-tracker.test.ts
+++ b/src/main/providers/__tests__/provider-error-tracker.test.ts
@@ -10,7 +10,7 @@ import type { ProviderSwitchConfig, FailoverSuggestion } from '../../../shared/a
describe('ProviderErrorTracker', () => {
let tracker: ProviderErrorTracker;
- let onFailoverSuggest: ReturnType;
+ let onFailoverSuggest: ReturnType void>>;
const defaultConfig: ProviderSwitchConfig = {
enabled: true,
promptBeforeSwitch: true,
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 589182896..784fca0bf 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -4955,6 +4955,7 @@ You are taking over this conversation. Based on the context above, provide a bri
targetProvider: ToolType;
groomContext: boolean;
archiveSource: boolean;
+ mergeBackInto?: Session;
}) => {
if (!switchProviderSession) return;
@@ -4967,17 +4968,29 @@ You are taking over this conversation. Based on the context above, provide a bri
targetProvider: request.targetProvider,
groomContext: request.groomContext,
archiveSource: request.archiveSource,
+ mergeBackInto: request.mergeBackInto,
});
if (result.success && result.newSession) {
- // Add the new session to state
- setSessions(prev => [...prev, result.newSession!]);
+ if (result.mergedBack && request.mergeBackInto) {
+ // Merge-back: replace the archived session with the reactivated one
+ setSessions(prev => prev.map(s =>
+ s.id === request.mergeBackInto!.id ? result.newSession! : s
+ ));
+ } else {
+ // Always-new: add the new session to state
+ setSessions(prev => [...prev, result.newSession!]);
+ }
// Mark source as archived if requested
if (request.archiveSource) {
setSessions(prev => prev.map(s =>
s.id === switchProviderSession.id
- ? { ...s, archivedByMigration: true, migratedToSessionId: result.newSessionId }
+ ? {
+ ...s,
+ archivedByMigration: true,
+ migratedToSessionId: result.newSessionId,
+ }
: s
));
}
@@ -4985,14 +4998,15 @@ You are taking over this conversation. Based on the context above, provide a bri
// Clear provider error tracking for source session
window.maestro.providers.clearSessionErrors(switchProviderSession.id).catch(() => {});
- // Navigate to the new session
+ // Navigate to the new/reactivated session
setActiveSessionId(result.newSessionId!);
// Show success toast
+ const action = result.mergedBack ? 'Merged back to' : 'Switched to';
notifyToast({
type: 'success',
title: 'Provider Switched',
- message: `Switched to ${getAgentDisplayName(request.targetProvider)}`,
+ message: `${action} ${getAgentDisplayName(request.targetProvider)}`,
duration: 5_000,
});
}
@@ -9713,6 +9727,7 @@ You are taking over this conversation. Based on the context above, provide a bri
}}
sourceSession={switchProviderSession}
sourceTabId={getActiveTab(switchProviderSession)?.id || ''}
+ sessions={sessions}
onConfirmSwitch={handleConfirmProviderSwitch}
/>
)}
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
index 65dedc6a0..5cdd5a096 100644
--- a/src/renderer/components/ProviderPanel.tsx
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -311,7 +311,7 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
{/* Failover Configuration */}
-
Provider Failover
+
Automatic Failover
{/* Enable automatic failover toggle */}
@@ -512,6 +512,48 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
+ {/* Switch Behavior */}
+
+
Switch Behavior
+
+ When switching back to a provider that already has an archived session:
+
+
+
+ saveConfig({ switchBehavior: 'merge-back' })}
+ />
+
+
+ Merge & update
+
+
+ Reactivate the archived session and append new context
+
+
+
+
+ saveConfig({ switchBehavior: 'always-new' })}
+ />
+
+
+ Always new
+
+
+ Create a fresh session each time
+
+
+
+
+
+
{/* Migration History */}
Migration History
diff --git a/src/renderer/components/SessionItem.tsx b/src/renderer/components/SessionItem.tsx
index a36bec4b6..c842d9093 100644
--- a/src/renderer/components/SessionItem.tsx
+++ b/src/renderer/components/SessionItem.tsx
@@ -1,5 +1,5 @@
import React, { memo } from 'react';
-import { Activity, GitBranch, Bot, Bookmark, AlertCircle, Server } from 'lucide-react';
+import { Activity, GitBranch, Bot, Bookmark, AlertCircle, Server, RefreshCw } from 'lucide-react';
import type { Session, Group, Theme } from '../types';
import { getStatusColor } from '../utils/theme';
@@ -213,6 +213,16 @@ export const SessionItem = memo(function SessionItem({
Provider switched — archived
)}
+ {/* Merge-back refresh indicator (session was reactivated with new context) */}
+ {!session.archivedByMigration && session.lastMergeBackAt && (
+
+
+ Context refreshed
+
+ )}
{/* Right side: Indicators and actions */}
diff --git a/src/renderer/components/SwitchProviderModal.tsx b/src/renderer/components/SwitchProviderModal.tsx
index af10cb14a..6d268e5fe 100644
--- a/src/renderer/components/SwitchProviderModal.tsx
+++ b/src/renderer/components/SwitchProviderModal.tsx
@@ -5,6 +5,10 @@
* archive source) before initiating a provider switch. Shows current provider,
* available targets with availability status, and estimated token count.
*
+ * When a target provider is selected and an archived session on that provider
+ * exists in the provenance chain, shows a merge-back panel letting users choose
+ * to reactivate the existing session instead of creating a new one.
+ *
* Pattern references:
* - AccountSwitchModal for themed modal structure
* - SendToAgentModal for agent selection + keyboard navigation
@@ -12,13 +16,16 @@
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
-import { ArrowDown, Shuffle } from 'lucide-react';
+import { ArrowDown, Shuffle, Info } from 'lucide-react';
import type { Theme, Session, ToolType, AgentConfig } from '../types';
+import type { ProviderSwitchBehavior } from '../../shared/account-types';
+import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
import { Modal } from './ui/Modal';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { getAgentIcon } from '../constants/agentIcons';
import { getAgentDisplayName } from '../services/contextGroomer';
import { formatTokensCompact } from '../utils/formatters';
+import { findArchivedPredecessor } from '../hooks/agent/useProviderSwitch';
export interface SwitchProviderModalProps {
theme: Theme;
@@ -28,11 +35,15 @@ export interface SwitchProviderModalProps {
sourceSession: Session;
/** Active tab ID for context extraction */
sourceTabId: string;
+ /** All sessions (for provenance chain walking) */
+ sessions: Session[];
/** Callback when user confirms the switch */
onConfirmSwitch: (request: {
targetProvider: ToolType;
groomContext: boolean;
archiveSource: boolean;
+ /** If set, merge back into this session instead of creating new */
+ mergeBackInto?: Session;
}) => void;
}
@@ -51,12 +62,25 @@ function estimateTokensFromLogs(logs: { text: string }[]): number {
return Math.round(totalChars / 4);
}
+/** Format a timestamp as relative time (e.g., "2 hours ago"). */
+function formatRelativeTime(timestamp: number): string {
+ const diff = Date.now() - timestamp;
+ const minutes = Math.floor(diff / 60_000);
+ if (minutes < 1) return 'just now';
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
+ const days = Math.floor(hours / 24);
+ return `${days} day${days === 1 ? '' : 's'} ago`;
+}
+
export function SwitchProviderModal({
theme,
isOpen,
onClose,
sourceSession,
sourceTabId,
+ sessions,
onConfirmSwitch,
}: SwitchProviderModalProps) {
// Provider selection
@@ -67,9 +91,17 @@ export function SwitchProviderModal({
const [groomContext, setGroomContext] = useState(true);
const [archiveSource, setArchiveSource] = useState(true);
+ // Merge-back choice: 'new' = create new session, 'merge' = reactivate archived
+ const [mergeChoice, setMergeChoice] = useState<'new' | 'merge'>('merge');
+
// Detected agents
const [providers, setProviders] = useState
([]);
+ // Stored switch behavior preference
+ const [switchBehavior, setSwitchBehavior] = useState(
+ DEFAULT_PROVIDER_SWITCH_CONFIG.switchBehavior
+ );
+
// Ref for scrolling highlighted item into view
const highlightedRef = useRef(null);
@@ -115,6 +147,22 @@ export function SwitchProviderModal({
};
}, [isOpen, sourceSession.toolType]);
+ // Load switch behavior preference
+ useEffect(() => {
+ if (!isOpen) return;
+
+ (async () => {
+ try {
+ const saved = await window.maestro.settings.get('providerSwitchConfig');
+ if (saved && typeof saved === 'object' && 'switchBehavior' in (saved as Record)) {
+ setSwitchBehavior((saved as { switchBehavior: ProviderSwitchBehavior }).switchBehavior);
+ }
+ } catch {
+ // Use default
+ }
+ })();
+ }, [isOpen]);
+
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
@@ -122,8 +170,9 @@ export function SwitchProviderModal({
setHighlightedIndex(0);
setGroomContext(true);
setArchiveSource(true);
+ setMergeChoice(switchBehavior === 'merge-back' ? 'merge' : 'new');
}
- }, [isOpen]);
+ }, [isOpen, switchBehavior]);
// Scroll highlighted item into view
useEffect(() => {
@@ -143,6 +192,19 @@ export function SwitchProviderModal({
[providers]
);
+ // Find archived predecessor when target provider changes
+ const archivedPredecessor = useMemo(() => {
+ if (!selectedProvider) return null;
+ return findArchivedPredecessor(sessions, sourceSession, selectedProvider);
+ }, [sessions, sourceSession, selectedProvider]);
+
+ // Reset merge choice default when predecessor changes
+ useEffect(() => {
+ if (archivedPredecessor) {
+ setMergeChoice(switchBehavior === 'merge-back' ? 'merge' : 'new');
+ }
+ }, [archivedPredecessor, switchBehavior]);
+
// Handle confirm
const handleConfirm = useCallback(() => {
if (!selectedProvider) return;
@@ -150,8 +212,9 @@ export function SwitchProviderModal({
targetProvider: selectedProvider,
groomContext,
archiveSource,
+ mergeBackInto: archivedPredecessor && mergeChoice === 'merge' ? archivedPredecessor : undefined,
});
- }, [selectedProvider, groomContext, archiveSource, onConfirmSwitch]);
+ }, [selectedProvider, groomContext, archiveSource, archivedPredecessor, mergeChoice, onConfirmSwitch]);
// Keyboard navigation handler
const handleKeyDown = useCallback(
@@ -231,7 +294,9 @@ export function SwitchProviderModal({
color: theme.colors.accentForeground,
}}
>
- Switch Provider
+ {archivedPredecessor && mergeChoice === 'merge'
+ ? 'Merge & Switch'
+ : 'Switch Provider'}
}
@@ -373,6 +438,66 @@ export function SwitchProviderModal({
+ {/* Merge-back panel (when archived predecessor found) */}
+ {archivedPredecessor && selectedProvider && (
+
+
+
+
+
Previous {getAgentDisplayName(selectedProvider)} session found
+
+ “{archivedPredecessor.name || 'Unnamed Agent'}” was previously on {getAgentDisplayName(selectedProvider)} before switching to {currentProviderName}
+ {archivedPredecessor.migratedAt ? ` ${formatRelativeTime(archivedPredecessor.migratedAt)}` : ''}.
+
+
+
+
+ {/* Merge-back radio options */}
+
+
+ setMergeChoice('new')}
+ className="mt-0.5"
+ />
+
+
+ Create new session
+
+
+ Start fresh on {getAgentDisplayName(selectedProvider)} with transferred context (creates a new agent entry)
+
+
+
+
+ setMergeChoice('merge')}
+ className="mt-0.5"
+ />
+
+
+ Merge & update existing session
+
+
+ Reactivate the archived {getAgentDisplayName(selectedProvider)} session and append current context to it
+
+
+
+
+
+ )}
+
{/* Options */}
diff --git a/src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts b/src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts
new file mode 100644
index 000000000..14dc991c3
--- /dev/null
+++ b/src/renderer/hooks/agent/__tests__/useProviderSwitch.test.ts
@@ -0,0 +1,389 @@
+/**
+ * Tests for useProviderSwitch helpers.
+ * Validates findArchivedPredecessor provenance-chain walking and
+ * merge-back session reactivation logic.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { findArchivedPredecessor } from '../useProviderSwitch';
+import type { Session, ToolType } from '../../../types';
+
+// ---------------------------------------------------------------------------
+// Minimal session factory — only the fields findArchivedPredecessor touches
+// ---------------------------------------------------------------------------
+
+function makeSession(overrides: Partial
& { id: string; toolType: ToolType }): Session {
+ return {
+ name: overrides.id,
+ groupId: undefined,
+ state: 'idle',
+ cwd: '/tmp',
+ fullPath: '/tmp',
+ projectRoot: '/tmp',
+ aiLogs: [],
+ shellLogs: [],
+ workLog: [],
+ contextUsage: 0,
+ inputMode: 'ai',
+ aiPid: 0,
+ terminalPid: 0,
+ port: 3000,
+ isLive: false,
+ changedFiles: [],
+ isGitRepo: false,
+ fileTree: [],
+ fileExplorerExpanded: [],
+ fileExplorerScrollPos: 0,
+ executionQueue: [],
+ activeTimeMs: 0,
+ aiTabs: [],
+ activeTabId: '',
+ closedTabHistory: [],
+ filePreviewTabs: [],
+ activeFileTabId: null,
+ unifiedTabOrder: [],
+ unifiedClosedTabHistory: [],
+ ...overrides,
+ } as Session;
+}
+
+// ---------------------------------------------------------------------------
+// findArchivedPredecessor
+// ---------------------------------------------------------------------------
+
+describe('findArchivedPredecessor', () => {
+ it('should return null when there is no provenance chain', () => {
+ const current = makeSession({ id: 'A', toolType: 'claude-code' });
+ const result = findArchivedPredecessor([current], current, 'codex');
+ expect(result).toBeNull();
+ });
+
+ it('should return null when chain exists but no archived predecessor matches', () => {
+ const original = makeSession({
+ id: 'original',
+ toolType: 'claude-code',
+ // Not archived
+ });
+ const current = makeSession({
+ id: 'current',
+ toolType: 'codex',
+ migratedFromSessionId: 'original',
+ });
+ const sessions = [original, current];
+
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).toBeNull();
+ });
+
+ it('should find an archived predecessor matching the target provider', () => {
+ const original = makeSession({
+ id: 'original',
+ toolType: 'claude-code',
+ archivedByMigration: true,
+ migratedToSessionId: 'current',
+ });
+ const current = makeSession({
+ id: 'current',
+ toolType: 'codex',
+ migratedFromSessionId: 'original',
+ });
+ const sessions = [original, current];
+
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('original');
+ });
+
+ it('should skip non-archived predecessors in the chain', () => {
+ const grandparent = makeSession({
+ id: 'gp',
+ toolType: 'claude-code',
+ archivedByMigration: true,
+ migratedToSessionId: 'parent',
+ });
+ const parent = makeSession({
+ id: 'parent',
+ toolType: 'claude-code',
+ migratedFromSessionId: 'gp',
+ // NOT archived — was reactivated
+ });
+ const current = makeSession({
+ id: 'current',
+ toolType: 'codex',
+ migratedFromSessionId: 'parent',
+ });
+ const sessions = [grandparent, parent, current];
+
+ // parent is claude-code but NOT archived, so it should skip to grandparent
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('gp');
+ });
+
+ it('should return the first archived match walking backwards', () => {
+ const gp = makeSession({
+ id: 'gp',
+ toolType: 'claude-code',
+ archivedByMigration: true,
+ });
+ const parent = makeSession({
+ id: 'parent',
+ toolType: 'claude-code',
+ archivedByMigration: true,
+ migratedFromSessionId: 'gp',
+ });
+ const current = makeSession({
+ id: 'current',
+ toolType: 'codex',
+ migratedFromSessionId: 'parent',
+ });
+ const sessions = [gp, parent, current];
+
+ // Should find 'parent' first (closest archived predecessor)
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('parent');
+ });
+
+ it('should not return the current session even if it matches', () => {
+ const current = makeSession({
+ id: 'current',
+ toolType: 'claude-code',
+ archivedByMigration: true, // Matches everything — but is the current session
+ });
+ const sessions = [current];
+
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).toBeNull();
+ });
+
+ it('should handle cycles in the provenance chain without infinite loop', () => {
+ const a = makeSession({
+ id: 'A',
+ toolType: 'claude-code',
+ archivedByMigration: false,
+ migratedFromSessionId: 'B',
+ });
+ const b = makeSession({
+ id: 'B',
+ toolType: 'codex',
+ archivedByMigration: false,
+ migratedFromSessionId: 'A', // Cycle: B -> A -> B -> ...
+ });
+ const sessions = [a, b];
+
+ // Should terminate without hanging
+ const result = findArchivedPredecessor(sessions, a, 'codex');
+ expect(result).toBeNull(); // B is not archived
+ });
+
+ it('should handle a missing session in the chain gracefully', () => {
+ const current = makeSession({
+ id: 'current',
+ toolType: 'codex',
+ migratedFromSessionId: 'deleted-session', // Not in the sessions array
+ });
+ const sessions = [current];
+
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).toBeNull();
+ });
+
+ it('should skip predecessors with wrong toolType', () => {
+ const original = makeSession({
+ id: 'original',
+ toolType: 'opencode', // Wrong provider type
+ archivedByMigration: true,
+ migratedToSessionId: 'current',
+ });
+ const current = makeSession({
+ id: 'current',
+ toolType: 'codex',
+ migratedFromSessionId: 'original',
+ });
+ const sessions = [original, current];
+
+ // Looking for claude-code, but predecessor is opencode
+ const result = findArchivedPredecessor(sessions, current, 'claude-code');
+ expect(result).toBeNull();
+ });
+
+ it('should walk a multi-hop chain to find a distant predecessor', () => {
+ // Chain: D (current, codex) -> C (opencode, not archived) -> B (codex, archived) -> A (claude-code, archived)
+ const a = makeSession({
+ id: 'A',
+ toolType: 'claude-code',
+ archivedByMigration: true,
+ });
+ const b = makeSession({
+ id: 'B',
+ toolType: 'codex',
+ archivedByMigration: true,
+ migratedFromSessionId: 'A',
+ });
+ const c = makeSession({
+ id: 'C',
+ toolType: 'opencode',
+ archivedByMigration: false, // Not archived
+ migratedFromSessionId: 'B',
+ });
+ const d = makeSession({
+ id: 'D',
+ toolType: 'codex',
+ migratedFromSessionId: 'C',
+ });
+ const sessions = [a, b, c, d];
+
+ // Looking for claude-code: should walk D -> C -> B -> A and find A
+ const result = findArchivedPredecessor(sessions, d, 'claude-code');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('A');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Merge-back session reactivation logic
+// ---------------------------------------------------------------------------
+
+describe('merge-back session construction', () => {
+ it('reactivated session preserves original identity fields', () => {
+ const archived = makeSession({
+ id: 'original-id',
+ toolType: 'claude-code',
+ name: 'My Project',
+ cwd: '/home/user/project',
+ projectRoot: '/home/user/project',
+ groupId: 'group-1',
+ bookmarked: true,
+ archivedByMigration: true,
+ migrationGeneration: 1,
+ migratedToSessionId: 'source-id',
+ aiTabs: [{
+ id: 'tab-1',
+ agentSessionId: null,
+ name: null,
+ starred: false,
+ logs: [{ id: 'log-1', timestamp: 1000, source: 'user' as const, text: 'hello' }],
+ inputValue: '',
+ stagedImages: [],
+ createdAt: 1000,
+ state: 'idle' as const,
+ saveToHistory: true,
+ showThinking: 'off' as const,
+ }],
+ });
+
+ const sourceSession = makeSession({
+ id: 'source-id',
+ toolType: 'codex',
+ name: 'My Project',
+ migratedFromSessionId: 'original-id',
+ });
+
+ // Simulate the reactivation spread from the hook
+ const reactivated: Session = {
+ ...archived,
+ archivedByMigration: false,
+ migratedFromSessionId: sourceSession.id,
+ migratedAt: Date.now(),
+ migrationGeneration: (archived.migrationGeneration || 0) + 1,
+ migratedToSessionId: undefined,
+ lastMergeBackAt: Date.now(),
+ };
+
+ // Identity preserved
+ expect(reactivated.id).toBe('original-id');
+ expect(reactivated.name).toBe('My Project');
+ expect(reactivated.cwd).toBe('/home/user/project');
+ expect(reactivated.projectRoot).toBe('/home/user/project');
+ expect(reactivated.groupId).toBe('group-1');
+ expect(reactivated.bookmarked).toBe(true);
+ expect(reactivated.toolType).toBe('claude-code');
+
+ // Provenance updated
+ expect(reactivated.archivedByMigration).toBe(false);
+ expect(reactivated.migratedFromSessionId).toBe('source-id');
+ expect(reactivated.migrationGeneration).toBe(2);
+ expect(reactivated.migratedToSessionId).toBeUndefined();
+ expect(reactivated.lastMergeBackAt).toBeGreaterThan(0);
+ });
+
+ it('context logs are appended with separator to existing tab logs', () => {
+ const existingLog = { id: 'existing-1', timestamp: 1000, source: 'user' as const, text: 'original context' };
+ const archived = makeSession({
+ id: 'original-id',
+ toolType: 'claude-code',
+ archivedByMigration: true,
+ aiTabs: [{
+ id: 'tab-1',
+ agentSessionId: null,
+ name: null,
+ starred: false,
+ logs: [existingLog],
+ inputValue: '',
+ stagedImages: [],
+ createdAt: 1000,
+ state: 'idle' as const,
+ saveToHistory: true,
+ showThinking: 'off' as const,
+ }],
+ });
+
+ const newContextLogs = [
+ { id: 'new-1', timestamp: 2000, source: 'user' as const, text: 'new context from codex' },
+ ];
+
+ // Simulate the merge-back append logic from the hook
+ const reactivated = { ...archived, archivedByMigration: false };
+ const mergeTab = { ...reactivated.aiTabs[0] };
+
+ const separator = {
+ id: `merge-separator-${Date.now()}`,
+ timestamp: Date.now(),
+ source: 'system' as const,
+ text: '── Context merged from Codex session ──',
+ };
+
+ const switchNotice = {
+ id: `provider-switch-notice-${Date.now()}`,
+ timestamp: Date.now(),
+ source: 'system' as const,
+ text: 'Provider switched back from Codex to Claude Code. Context groomed and optimized.',
+ };
+
+ mergeTab.logs = [...mergeTab.logs, separator, switchNotice, ...newContextLogs];
+
+ // Original log preserved at start
+ expect(mergeTab.logs[0]).toBe(existingLog);
+ // Separator added
+ expect(mergeTab.logs[1].source).toBe('system');
+ expect(mergeTab.logs[1].text).toContain('Context merged from');
+ // Switch notice
+ expect(mergeTab.logs[2].source).toBe('system');
+ expect(mergeTab.logs[2].text).toContain('Provider switched back');
+ // New context appended
+ expect(mergeTab.logs[3].text).toBe('new context from codex');
+ // Total: 1 existing + 1 separator + 1 notice + 1 new = 4
+ expect(mergeTab.logs).toHaveLength(4);
+ });
+
+ it('source session is correctly marked as archived after merge-back', () => {
+ const source = makeSession({
+ id: 'source-id',
+ toolType: 'codex',
+ });
+
+ // Simulate source archiving (done in App.tsx)
+ const archivedSource: Session = {
+ ...source,
+ archivedByMigration: true,
+ migratedToSessionId: 'target-id',
+ };
+
+ expect(archivedSource.archivedByMigration).toBe(true);
+ expect(archivedSource.migratedToSessionId).toBe('target-id');
+ // Original identity preserved
+ expect(archivedSource.id).toBe('source-id');
+ expect(archivedSource.toolType).toBe('codex');
+ });
+});
diff --git a/src/renderer/hooks/agent/useProviderSwitch.ts b/src/renderer/hooks/agent/useProviderSwitch.ts
index c0c4bec84..6679921cc 100644
--- a/src/renderer/hooks/agent/useProviderSwitch.ts
+++ b/src/renderer/hooks/agent/useProviderSwitch.ts
@@ -47,6 +47,12 @@ export interface ProviderSwitchRequest {
groomContext: boolean;
/** Whether to auto-archive source session after switch */
archiveSource: boolean;
+ /**
+ * When set, reactivate this archived session instead of creating a new one.
+ * The groomed context from the source is appended to the target session's logs.
+ * Mutually exclusive with createMergedSession — uses session mutation instead.
+ */
+ mergeBackInto?: Session;
}
export interface ProviderSwitchResult {
@@ -59,6 +65,8 @@ export interface ProviderSwitchResult {
newTabId?: string;
/** Tokens saved via grooming */
tokensSaved?: number;
+ /** Whether this was a merge-back into an existing session */
+ mergedBack?: boolean;
/** Error message (if failed) */
error?: string;
}
@@ -72,6 +80,43 @@ export interface UseProviderSwitchResult {
reset: () => void;
}
+// ============================================================================
+// Helpers
+// ============================================================================
+
+/**
+ * Walk the provenance chain backwards from `currentSession` to find
+ * an archived session running `targetProvider`.
+ */
+export function findArchivedPredecessor(
+ sessions: Session[],
+ currentSession: Session,
+ targetProvider: ToolType,
+): Session | null {
+ let cursor: Session | undefined = currentSession;
+ const visited = new Set();
+
+ while (cursor) {
+ if (visited.has(cursor.id)) break; // prevent cycles
+ visited.add(cursor.id);
+
+ if (
+ cursor.archivedByMigration &&
+ cursor.toolType === targetProvider &&
+ cursor.id !== currentSession.id
+ ) {
+ return cursor;
+ }
+
+ if (cursor.migratedFromSessionId) {
+ cursor = sessions.find(s => s.id === cursor!.migratedFromSessionId);
+ } else {
+ break;
+ }
+ }
+ return null;
+}
+
// ============================================================================
// Constants
// ============================================================================
@@ -144,7 +189,7 @@ export function useProviderSwitch(): UseProviderSwitchResult {
*/
const switchProvider = useCallback(
async (request: ProviderSwitchRequest): Promise => {
- const { sourceSession, sourceTabId, targetProvider, groomContext } = request;
+ const { sourceSession, sourceTabId, targetProvider, groomContext, mergeBackInto } = request;
const store = useOperationStore.getState();
@@ -294,53 +339,105 @@ export function useProviderSwitch(): UseProviderSwitchResult {
return { success: false, error: 'Provider switch cancelled' };
}
- // Step 4: Create new session via extended createMergedSession
- useOperationStore.getState().setTransferState({
- state: 'creating',
- progress: {
- stage: 'creating',
- progress: 80,
- message: `Creating ${getAgentDisplayName(targetProvider)} session...`,
- },
- });
-
- const { session: newSession, tabId: newTabId } = createMergedSession({
- name: sourceSession.name,
- projectRoot: sourceSession.projectRoot,
- toolType: targetProvider,
- mergedLogs: contextLogs,
- groupId: sourceSession.groupId,
- // Identity carry-over
- nudgeMessage: sourceSession.nudgeMessage,
- bookmarked: sourceSession.bookmarked,
- sessionSshRemoteConfig: sourceSession.sessionSshRemoteConfig,
- autoRunFolderPath: sourceSession.autoRunFolderPath,
- // Provenance
- migratedFromSessionId: sourceSession.id,
- migratedAt: Date.now(),
- migrationGeneration: (sourceSession.migrationGeneration || 0) + 1,
- });
-
- // Step 5: Add transfer notice to new session tab
const sourceName = getAgentDisplayName(sourceSession.toolType);
const targetName = getAgentDisplayName(targetProvider);
const groomNote = groomContext
? 'Context groomed and optimized.'
: 'Context preserved as-is.';
- const transferNotice: LogEntry = {
- id: `provider-switch-notice-${Date.now()}`,
- timestamp: Date.now(),
- source: 'system',
- text: `Provider switched from ${sourceName} to ${targetName}. ${groomNote}`,
- };
+ let resultSession: Session;
+ let resultTabId: string;
+
+ if (mergeBackInto) {
+ // Step 4a: Merge-back mode — reactivate the archived session
+ useOperationStore.getState().setTransferState({
+ state: 'creating',
+ progress: {
+ stage: 'creating',
+ progress: 80,
+ message: `Reactivating ${targetName} session...`,
+ },
+ });
+
+ // Reactivate the archived session by mutating its fields
+ const reactivated: Session = {
+ ...mergeBackInto,
+ archivedByMigration: false,
+ migratedFromSessionId: sourceSession.id,
+ migratedAt: Date.now(),
+ migrationGeneration: (mergeBackInto.migrationGeneration || 0) + 1,
+ migratedToSessionId: undefined,
+ lastMergeBackAt: Date.now(),
+ };
+
+ // Append context logs to the reactivated session's active tab
+ const mergeTab = reactivated.aiTabs[0];
+ if (mergeTab) {
+ const separator: LogEntry = {
+ id: `merge-separator-${Date.now()}`,
+ timestamp: Date.now(),
+ source: 'system',
+ text: `── Context merged from ${sourceName} session ──`,
+ };
+
+ const switchNotice: LogEntry = {
+ id: `provider-switch-notice-${Date.now()}`,
+ timestamp: Date.now(),
+ source: 'system',
+ text: `Provider switched back from ${sourceName} to ${targetName}. ${groomNote}`,
+ };
+
+ mergeTab.logs = [...mergeTab.logs, separator, switchNotice, ...contextLogs];
+ }
+
+ resultSession = reactivated;
+ resultTabId = mergeTab?.id || reactivated.aiTabs[0]?.id || '';
+ } else {
+ // Step 4b: Create new session via extended createMergedSession
+ useOperationStore.getState().setTransferState({
+ state: 'creating',
+ progress: {
+ stage: 'creating',
+ progress: 80,
+ message: `Creating ${targetName} session...`,
+ },
+ });
+
+ const { session: newSession, tabId: newTabId } = createMergedSession({
+ name: sourceSession.name,
+ projectRoot: sourceSession.projectRoot,
+ toolType: targetProvider,
+ mergedLogs: contextLogs,
+ groupId: sourceSession.groupId,
+ // Identity carry-over
+ nudgeMessage: sourceSession.nudgeMessage,
+ bookmarked: sourceSession.bookmarked,
+ sessionSshRemoteConfig: sourceSession.sessionSshRemoteConfig,
+ autoRunFolderPath: sourceSession.autoRunFolderPath,
+ // Provenance
+ migratedFromSessionId: sourceSession.id,
+ migratedAt: Date.now(),
+ migrationGeneration: (sourceSession.migrationGeneration || 0) + 1,
+ });
+
+ // Add transfer notice to new session tab
+ const transferNotice: LogEntry = {
+ id: `provider-switch-notice-${Date.now()}`,
+ timestamp: Date.now(),
+ source: 'system',
+ text: `Provider switched from ${sourceName} to ${targetName}. ${groomNote}`,
+ };
+
+ const activeTab = newSession.aiTabs.find((t) => t.id === newTabId);
+ if (activeTab) {
+ activeTab.logs = [transferNotice, ...activeTab.logs];
+ }
- const activeTab = newSession.aiTabs.find((t) => t.id === newTabId);
- if (activeTab) {
- activeTab.logs = [transferNotice, ...activeTab.logs];
+ resultSession = newSession;
+ resultTabId = newTabId;
}
- // Step 6: Complete
+ // Step 5: Complete
useOperationStore.getState().setTransferState({
state: 'complete',
progress: {
@@ -352,10 +449,11 @@ export function useProviderSwitch(): UseProviderSwitchResult {
return {
success: true,
- newSession,
- newSessionId: newSession.id,
- newTabId,
+ newSession: resultSession,
+ newSessionId: resultSession.id,
+ newTabId: resultTabId,
tokensSaved,
+ mergedBack: !!mergeBackInto,
};
} catch (err) {
const errorMessage =
diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts
index 4225d4812..b2a5952b5 100644
--- a/src/renderer/types/index.ts
+++ b/src/renderer/types/index.ts
@@ -722,6 +722,8 @@ export interface Session {
archivedByMigration?: boolean;
/** Migration generation counter (0 = original, increments with each switch) */
migrationGeneration?: number;
+ /** Timestamp of last merge-back (when an archived session was reactivated with new context) */
+ lastMergeBackAt?: number;
}
export interface AgentConfigOption {
diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts
index 9d58d0927..64abf67f9 100644
--- a/src/shared/account-types.ts
+++ b/src/shared/account-types.ts
@@ -131,6 +131,9 @@ export const DEFAULT_TOKEN_WINDOW_MS = 5 * 60 * 60 * 1000;
import type { ToolType, AgentErrorType } from './types';
+/** Controls what happens when switching back to a provider with an existing archived session. */
+export type ProviderSwitchBehavior = 'always-new' | 'merge-back';
+
/**
* Configuration for automated provider failover (Virtuosos vertical swapping).
* Stored in settings alongside account switch config.
@@ -146,6 +149,8 @@ export interface ProviderSwitchConfig {
errorWindowMs: number;
/** Ordered list of fallback providers (tried in order) */
fallbackProviders: ToolType[];
+ /** Default behavior when switching back to a provider with an archived session */
+ switchBehavior: ProviderSwitchBehavior;
}
export const DEFAULT_PROVIDER_SWITCH_CONFIG: ProviderSwitchConfig = {
@@ -154,6 +159,7 @@ export const DEFAULT_PROVIDER_SWITCH_CONFIG: ProviderSwitchConfig = {
errorThreshold: 3,
errorWindowMs: 5 * 60 * 1000, // 5 minutes
fallbackProviders: [],
+ switchBehavior: 'merge-back',
};
/**
From 1d0012cfb63cda98ca6b492061f269a5d1db3bed Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 18:43:22 -0500
Subject: [PATCH 45/59] MAESTRO: add time range selector and usage totals bar
to Provider Health Dashboard
Extends useProviderHealth hook with StatsTimeRange state and per-provider
usage stats aggregation. Adds totals summary bar above health cards showing
combined queries, tokens, and cost. Moves auto-refresh indicator and refresh
button to a footer row with time range dropdown (Today/Week/Month/Quarter/All).
Updates health cards to show queries, tokens, and cost metrics. Subscribes to
onStatsUpdate for immediate dashboard refresh on new query events.
Co-Authored-By: Claude Opus 4.6
---
.../components/ProviderHealthCard.tsx | 51 ++++---
src/renderer/components/ProviderPanel.tsx | 108 ++++++++++++---
src/renderer/hooks/useProviderHealth.ts | 125 ++++++++++++++++--
3 files changed, 233 insertions(+), 51 deletions(-)
diff --git a/src/renderer/components/ProviderHealthCard.tsx b/src/renderer/components/ProviderHealthCard.tsx
index a4c1fceb3..df51d1f5d 100644
--- a/src/renderer/components/ProviderHealthCard.tsx
+++ b/src/renderer/components/ProviderHealthCard.tsx
@@ -4,7 +4,7 @@
* Displays:
* - Provider icon and name
* - Health status badge (Healthy/Degraded/Failing/Not Installed/Idle)
- * - Stats grid: sessions, errors, error rate, last error
+ * - Stats grid: sessions, queries, tokens, cost, errors, last error
* - Health bar at bottom (green/yellow/red gradient)
*/
@@ -12,8 +12,10 @@ import React from 'react';
import type { Theme } from '../types';
import type { ToolType } from '../../shared/types';
import type { ProviderErrorStats } from '../../shared/account-types';
+import type { ProviderUsageStats } from '../hooks/useProviderHealth';
import { getAgentIcon } from '../constants/agentIcons';
import { getAgentDisplayName } from '../services/contextGroomer';
+import { formatTokenCount } from '../hooks/useAccountUsage';
// ============================================================================
// Types
@@ -27,6 +29,7 @@ export interface ProviderHealthCardProps {
available: boolean;
activeSessionCount: number;
errorStats: ProviderErrorStats | null;
+ usageStats: ProviderUsageStats;
failoverThreshold: number;
healthPercent: number;
status: HealthStatus;
@@ -112,6 +115,7 @@ export function ProviderHealthCard({
available,
activeSessionCount,
errorStats,
+ usageStats,
failoverThreshold,
healthPercent,
status,
@@ -124,13 +128,8 @@ export function ProviderHealthCard({
? theme.colors.textDim + '30'
: getHealthBarColor(healthPercent, theme);
- // Approximate error rate: errors / (errors + some baseline)
- // Since we don't have total responses, show raw error count and threshold fraction
- const errorRateDisplay = status === 'not_installed' || status === 'idle'
- ? '\u2014'
- : errorCount === 0
- ? '0%'
- : `${Math.min(100, Math.round((errorCount / failoverThreshold) * 100))}%`;
+ const isUnavailable = status === 'not_installed';
+ const dash = '\u2014';
return (
+
+
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
index 5cdd5a096..28bcd22c4 100644
--- a/src/renderer/components/ProviderPanel.tsx
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -18,12 +18,14 @@ import {
} from 'lucide-react';
import type { Theme, Session } from '../types';
import type { ToolType } from '../../shared/types';
+import type { StatsTimeRange } from '../../shared/stats-types';
import type { ProviderSwitchConfig } from '../../shared/account-types';
import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
import { getAgentIcon } from '../constants/agentIcons';
import { getAgentDisplayName } from '../services/contextGroomer';
import { ProviderHealthCard } from './ProviderHealthCard';
import { useProviderHealth } from '../hooks/useProviderHealth';
+import { formatTokenCount } from '../hooks/useAccountUsage';
// ============================================================================
// Types
@@ -56,6 +58,14 @@ const ERROR_WINDOW_OPTIONS = [
const MIGRATION_HISTORY_LIMIT = 20;
+const TIME_RANGE_OPTIONS: { label: string; value: StatsTimeRange }[] = [
+ { label: 'Today', value: 'day' },
+ { label: 'This Week', value: 'week' },
+ { label: 'This Month', value: 'month' },
+ { label: 'This Quarter', value: 'quarter' },
+ { label: 'All Time', value: 'all' },
+];
+
// ============================================================================
// Helpers
// ============================================================================
@@ -96,8 +106,11 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
providers: healthProviders,
isLoading: healthLoading,
lastUpdated,
+ timeRange,
+ setTimeRange,
refresh: refreshHealth,
failoverThreshold,
+ totals,
} = useProviderHealth(sessions);
const [config, setConfig] = useState(DEFAULT_PROVIDER_SWITCH_CONFIG);
const [showMoreHistory, setShowMoreHistory] = useState(false);
@@ -204,32 +217,42 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
fontSize: 11,
};
+ const timeRangeLabel = TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label?.toLowerCase() ?? 'today';
+
// ── Render ───────────────────────────────────────────────────────────
return (
{/* Provider Health Dashboard */}
-
-
Provider Health
-
-
- Auto-refresh: every 10s
+ Provider Health
+
+ {/* Totals summary bar */}
+ {!healthLoading && healthProviders.length > 0 && (
+
+
+ Total {timeRangeLabel}:
+
+
+ {totals.queryCount.toLocaleString()} queries
+
+ ·
+
+ {formatTokenCount(totals.totalTokens)} tokens
+
+ ·
+
+ ${totals.totalCostUsd.toFixed(2)} cost
-
-
- Refresh
-
-
+ )}
{healthLoading && healthProviders.length === 0 ? (
)}
+
+ {/* Footer: time range selector, auto-refresh, refresh button */}
+
+
+ Time range:
+ setTimeRange(e.target.value as StatsTimeRange)}
+ style={{
+ backgroundColor: theme.colors.bgMain,
+ color: theme.colors.textMain,
+ border: `1px solid ${theme.colors.border}`,
+ borderRadius: 4,
+ padding: '2px 6px',
+ fontSize: 11,
+ }}
+ >
+ {TIME_RANGE_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+ Auto-refresh: 10s
+
+
+
+ Refresh
+
+
+
{/* Failover Configuration */}
diff --git a/src/renderer/hooks/useProviderHealth.ts b/src/renderer/hooks/useProviderHealth.ts
index a28e3d117..9b2d34654 100644
--- a/src/renderer/hooks/useProviderHealth.ts
+++ b/src/renderer/hooks/useProviderHealth.ts
@@ -1,15 +1,16 @@
/**
* useProviderHealth - Live provider health data with auto-refresh
*
- * Combines agent detection, error stats, and session counts into
+ * Combines agent detection, error stats, usage stats, and session counts into
* per-provider health data. Polls on an interval and refreshes
- * immediately on failover suggestions.
+ * immediately on failover suggestions and new query events.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Session, AgentConfig } from '../types';
import type { ToolType } from '../../shared/types';
import type { ProviderErrorStats, ProviderSwitchConfig } from '../../shared/account-types';
+import type { StatsTimeRange } from '../../shared/stats-types';
import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
import { getAgentDisplayName } from '../services/contextGroomer';
import type { HealthStatus } from '../components/ProviderHealthCard';
@@ -18,24 +19,56 @@ import type { HealthStatus } from '../components/ProviderHealthCard';
// Types
// ============================================================================
+export interface ProviderUsageStats {
+ queryCount: number;
+ totalInputTokens: number;
+ totalOutputTokens: number;
+ totalCacheReadTokens: number;
+ totalCacheCreationTokens: number;
+ totalCostUsd: number;
+ totalDurationMs: number;
+ avgDurationMs: number;
+}
+
+const EMPTY_USAGE_STATS: ProviderUsageStats = {
+ queryCount: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCacheReadTokens: 0,
+ totalCacheCreationTokens: 0,
+ totalCostUsd: 0,
+ totalDurationMs: 0,
+ avgDurationMs: 0,
+};
+
export interface ProviderHealth {
toolType: ToolType;
available: boolean;
displayName: string;
activeSessionCount: number;
errorStats: ProviderErrorStats | null;
+ usageStats: ProviderUsageStats;
healthPercent: number;
status: HealthStatus;
}
+export interface UsageTotals {
+ queryCount: number;
+ totalTokens: number;
+ totalCostUsd: number;
+}
+
export interface UseProviderHealthResult {
providers: ProviderHealth[];
isLoading: boolean;
lastUpdated: number | null;
+ timeRange: StatsTimeRange;
+ setTimeRange: (range: StatsTimeRange) => void;
refresh: () => void;
failoverThreshold: number;
hasDegradedProvider: boolean;
hasFailingProvider: boolean;
+ totals: UsageTotals;
}
// ============================================================================
@@ -73,6 +106,36 @@ function computeStatus(
const DEFAULT_REFRESH_INTERVAL = 10_000;
+/** Aggregate raw query events into per-provider usage stats */
+function aggregateUsageByProvider(
+ events: Array<{ agentType: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number; costUsd?: number; duration?: number }>,
+): Record
{
+ const byProvider: Record = {};
+
+ for (const e of events) {
+ if (!byProvider[e.agentType]) {
+ byProvider[e.agentType] = { ...EMPTY_USAGE_STATS };
+ }
+ const acc = byProvider[e.agentType];
+ acc.queryCount += 1;
+ acc.totalInputTokens += e.inputTokens ?? 0;
+ acc.totalOutputTokens += e.outputTokens ?? 0;
+ acc.totalCacheReadTokens += e.cacheReadTokens ?? 0;
+ acc.totalCacheCreationTokens += e.cacheCreationTokens ?? 0;
+ acc.totalCostUsd += e.costUsd ?? 0;
+ acc.totalDurationMs += e.duration ?? 0;
+ }
+
+ // Compute averages
+ for (const stats of Object.values(byProvider)) {
+ stats.avgDurationMs = stats.queryCount > 0
+ ? Math.round(stats.totalDurationMs / stats.queryCount)
+ : 0;
+ }
+
+ return byProvider;
+}
+
export function useProviderHealth(
sessions: Session[] | undefined,
refreshIntervalMs: number = DEFAULT_REFRESH_INTERVAL,
@@ -80,24 +143,50 @@ export function useProviderHealth(
const [providers, setProviders] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState(null);
+ const [timeRange, setTimeRange] = useState('day');
+ const [totals, setTotals] = useState({ queryCount: 0, totalTokens: 0, totalCostUsd: 0 });
const [failoverThreshold, setFailoverThreshold] = useState(
DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold,
);
const intervalRef = useRef | null>(null);
+ const timeRangeRef = useRef(timeRange);
+ timeRangeRef.current = timeRange;
const refresh = useCallback(async () => {
try {
- // Fetch availability, error stats, and failover config in parallel
- const [agents, errorStatsRecord, savedConfig] = await Promise.all([
+ // Fetch availability, error stats, failover config, and usage stats in parallel
+ const [agents, errorStatsRecord, savedConfig, queryEvents] = await Promise.all([
window.maestro.agents.detect() as Promise,
window.maestro.providers.getAllErrorStats() as Promise>,
window.maestro.settings.get('providerSwitchConfig') as Promise | null>,
+ window.maestro.stats.getStats(timeRangeRef.current) as Promise>,
]);
const threshold = (savedConfig as Partial)?.errorThreshold
?? DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold;
setFailoverThreshold(threshold);
+ const usageByProvider = aggregateUsageByProvider(queryEvents);
+
+ // Compute totals across all providers
+ let totalQueries = 0;
+ let totalTokens = 0;
+ let totalCost = 0;
+ for (const stats of Object.values(usageByProvider)) {
+ totalQueries += stats.queryCount;
+ totalTokens += stats.totalInputTokens + stats.totalOutputTokens;
+ totalCost += stats.totalCostUsd;
+ }
+ setTotals({ queryCount: totalQueries, totalTokens, totalCostUsd: totalCost });
+
const sessionList = sessions ?? [];
const healthData: ProviderHealth[] = agents
@@ -107,8 +196,8 @@ export function useProviderHealth(
const activeCount = sessionList.filter(
(s) => s.toolType === toolType && !s.archivedByMigration,
).length;
- const stats = errorStatsRecord[toolType] ?? null;
- const errorCount = stats?.totalErrorsInWindow ?? 0;
+ const errorStats = errorStatsRecord[toolType] ?? null;
+ const errorCount = errorStats?.totalErrorsInWindow ?? 0;
const healthPercent = computeHealthPercent(
agent.available,
@@ -128,7 +217,8 @@ export function useProviderHealth(
available: agent.available,
displayName: getAgentDisplayName(toolType),
activeSessionCount: activeCount,
- errorStats: stats,
+ errorStats,
+ usageStats: usageByProvider[toolType] ?? { ...EMPTY_USAGE_STATS },
healthPercent,
status,
};
@@ -153,12 +243,22 @@ export function useProviderHealth(
};
}, [refresh, refreshIntervalMs]);
+ // Re-fetch when time range changes
+ useEffect(() => {
+ refresh();
+ }, [timeRange]); // eslint-disable-line react-hooks/exhaustive-deps
+
// Subscribe to failover suggestions for immediate refresh (Task 4)
useEffect(() => {
- const cleanup = window.maestro.providers?.onFailoverSuggest?.(() => {
- refresh();
- });
- return cleanup;
+ const cleanups: (() => void)[] = [];
+
+ const c1 = window.maestro.providers?.onFailoverSuggest?.(() => refresh());
+ if (c1) cleanups.push(c1);
+
+ const c2 = window.maestro.stats?.onStatsUpdate?.(() => refresh());
+ if (c2) cleanups.push(c2);
+
+ return () => cleanups.forEach((fn) => fn());
}, [refresh]);
const hasDegradedProvider = providers.some(
@@ -170,9 +270,12 @@ export function useProviderHealth(
providers,
isLoading,
lastUpdated,
+ timeRange,
+ setTimeRange,
refresh,
failoverThreshold,
hasDegradedProvider,
hasFailingProvider,
+ totals,
};
}
From ae2f7b372e5696433b2ff670b8594b7c8f881fa5 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 18:50:05 -0500
Subject: [PATCH 46/59] MAESTRO: add Provider Detail View with navigation,
summary stats, and useProviderDetail hook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Clicking a health card or its "Details →" link transitions to a full-width
detail view for that provider. The view shows 12 summary metrics (queries,
tokens, cost, reliability, error rate, sessions, source/location split,
avg/p95 response times), active sessions list, and migration history.
Back button and Escape key return to the card grid.
Co-Authored-By: Claude Opus 4.6
---
.../components/ProviderDetailView.tsx | 488 ++++++++++++++++++
.../components/ProviderHealthCard.tsx | 22 +-
src/renderer/components/ProviderPanel.tsx | 22 +
src/renderer/hooks/useProviderDetail.ts | 389 ++++++++++++++
4 files changed, 920 insertions(+), 1 deletion(-)
create mode 100644 src/renderer/components/ProviderDetailView.tsx
create mode 100644 src/renderer/hooks/useProviderDetail.ts
diff --git a/src/renderer/components/ProviderDetailView.tsx b/src/renderer/components/ProviderDetailView.tsx
new file mode 100644
index 000000000..b4ce41b35
--- /dev/null
+++ b/src/renderer/components/ProviderDetailView.tsx
@@ -0,0 +1,488 @@
+/**
+ * ProviderDetailView - Full-width detail view for a single provider
+ *
+ * Shows provider header with health status, 8 summary metrics,
+ * and navigates back to the card grid on back button or Escape key.
+ */
+
+import React, { useEffect } from 'react';
+import { ArrowLeft, ArrowRightLeft } from 'lucide-react';
+import type { Theme, Session } from '../types';
+import type { ToolType } from '../../shared/types';
+import type { StatsTimeRange } from '../../shared/stats-types';
+import { getAgentIcon } from '../constants/agentIcons';
+import { formatTokenCount } from '../hooks/useAccountUsage';
+import { useProviderDetail } from '../hooks/useProviderDetail';
+import type { HealthStatus } from './ProviderHealthCard';
+import { getAgentDisplayName } from '../services/contextGroomer';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ProviderDetailViewProps {
+ theme: Theme;
+ toolType: ToolType;
+ sessions: Session[];
+ timeRange: StatsTimeRange;
+ setTimeRange: (range: StatsTimeRange) => void;
+ onBack: () => void;
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function getStatusLabel(status: HealthStatus): string {
+ switch (status) {
+ case 'healthy': return 'Healthy';
+ case 'degraded': return 'Degraded';
+ case 'failing': return 'Failing';
+ case 'not_installed': return 'Not Installed';
+ case 'idle': return 'Idle';
+ }
+}
+
+function getStatusColor(status: HealthStatus, theme: Theme): string {
+ switch (status) {
+ case 'healthy': return theme.colors.success;
+ case 'degraded': return theme.colors.warning;
+ case 'failing': return theme.colors.error;
+ case 'not_installed': return theme.colors.textDim;
+ case 'idle': return theme.colors.accent;
+ }
+}
+
+function formatDurationMs(ms: number): string {
+ if (ms === 0) return '—';
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ return `${(ms / 1000).toFixed(1)}s`;
+}
+
+function formatMigrationTime(timestamp: number): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffHours = diffMs / (1000 * 60 * 60);
+
+ if (diffHours < 24) {
+ return date.toLocaleTimeString(undefined, {
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ }
+
+ return date.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+export function ProviderDetailView({
+ theme,
+ toolType,
+ sessions,
+ timeRange,
+ setTimeRange,
+ onBack,
+}: ProviderDetailViewProps) {
+ const { detail, isLoading } = useProviderDetail(toolType, sessions, timeRange);
+
+ // Handle Escape key to go back
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ onBack();
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onBack]);
+
+ const statusColor = detail ? getStatusColor(detail.status, theme) : theme.colors.textDim;
+
+ // Loading skeleton
+ if (isLoading && !detail) {
+ return (
+
+
+
+ Back to Providers
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ if (!detail) {
+ return (
+
+
+
+ Back to Providers
+
+
+ Failed to load provider details
+
+
+ );
+ }
+
+ const reliabilityDisplay = detail.usage.queryCount > 0
+ ? `${detail.reliability.successRate.toFixed(1)}%`
+ : 'N/A';
+ const errorRateDisplay = detail.usage.queryCount > 0
+ ? `${detail.reliability.errorRate.toFixed(1)}%`
+ : 'N/A';
+
+ return (
+
+ {/* Back button */}
+
{
+ e.currentTarget.style.opacity = '0.8';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.opacity = '1';
+ }}
+ >
+
+ Back to Providers
+
+
+ {/* Header: icon + name + status */}
+
+
+ {getAgentIcon(toolType)}
+
+ {detail.displayName}
+
+
+
+
+
+ {getStatusLabel(detail.status)}
+
+
+
+
+ {/* Summary stats row — 8 key metrics */}
+
+
+
+
+
+ = 95
+ ? theme.colors.success
+ : detail.reliability.successRate >= 85
+ ? theme.colors.warning
+ : theme.colors.error
+ }
+ />
+
+
+
+
+
+ {/* Avg Response Time row */}
+
+
+
+
+
+
+
+ {/* Active Sessions */}
+ {detail.activeSessions.length > 0 && (
+
+
+ Active Sessions ({detail.activeSessions.length})
+
+ {detail.activeSessions.map((s) => (
+
+
+ {s.name}
+
+
+ {s.projectRoot}
+
+
+ ● {s.state}
+
+
+ ))}
+
+ )}
+
+ {/* Migration History */}
+ {detail.migrations.length > 0 && (
+
+
+ Migration History
+
+ {detail.migrations.slice(0, 10).map((m, i) => (
+
+
+ {formatMigrationTime(m.timestamp)}
+
+
+ {m.sessionName}:
+
+
+ {m.direction === 'from' ? '→' : '←'}{' '}
+ {m.direction === 'from' ? 'Switched TO' : 'Switched FROM'}{' '}
+ {getAgentDisplayName(m.otherProvider)}
+
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Sub-components
+// ============================================================================
+
+function MetricCard({
+ theme,
+ label,
+ value,
+ valueColor,
+}: {
+ theme: Theme;
+ label: string;
+ value: string;
+ valueColor?: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export default ProviderDetailView;
diff --git a/src/renderer/components/ProviderHealthCard.tsx b/src/renderer/components/ProviderHealthCard.tsx
index df51d1f5d..9baae8379 100644
--- a/src/renderer/components/ProviderHealthCard.tsx
+++ b/src/renderer/components/ProviderHealthCard.tsx
@@ -33,6 +33,7 @@ export interface ProviderHealthCardProps {
failoverThreshold: number;
healthPercent: number;
status: HealthStatus;
+ onSelect?: () => void;
}
// ============================================================================
@@ -119,6 +120,7 @@ export function ProviderHealthCard({
failoverThreshold,
healthPercent,
status,
+ onSelect,
}: ProviderHealthCardProps) {
const errorCount = errorStats?.totalErrorsInWindow ?? 0;
const windowMs = 5 * 60 * 1000; // Default 5m window display
@@ -142,7 +144,9 @@ export function ProviderHealthCard({
minHeight: 160,
display: 'flex',
flexDirection: 'column',
+ cursor: onSelect ? 'pointer' : undefined,
}}
+ onClick={onSelect}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = `0 2px 8px ${theme.colors.border}80`;
}}
@@ -264,7 +268,8 @@ export function ProviderHealthCard({
@@ -276,6 +281,21 @@ export function ProviderHealthCard({
>
{status === 'not_installed' ? 'N/A' : `${Math.round(healthPercent)}%`}
+ {onSelect && (
+ {
+ e.stopPropagation();
+ onSelect();
+ }}
+ >
+ Details →
+
+ )}
);
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
index 28bcd22c4..812d9020a 100644
--- a/src/renderer/components/ProviderPanel.tsx
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -24,6 +24,7 @@ import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
import { getAgentIcon } from '../constants/agentIcons';
import { getAgentDisplayName } from '../services/contextGroomer';
import { ProviderHealthCard } from './ProviderHealthCard';
+import { ProviderDetailView } from './ProviderDetailView';
import { useProviderHealth } from '../hooks/useProviderHealth';
import { formatTokenCount } from '../hooks/useAccountUsage';
@@ -114,6 +115,7 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
} = useProviderHealth(sessions);
const [config, setConfig] = useState
(DEFAULT_PROVIDER_SWITCH_CONFIG);
const [showMoreHistory, setShowMoreHistory] = useState(false);
+ const [selectedProvider, setSelectedProvider] = useState(null);
// ── Load failover config ────────────────────────────────────────────
useEffect(() => {
@@ -220,6 +222,25 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
const timeRangeLabel = TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.label?.toLowerCase() ?? 'today';
// ── Render ───────────────────────────────────────────────────────────
+
+ // Detail view for a selected provider
+ if (selectedProvider) {
+ return (
+
+
+
setSelectedProvider(null)}
+ />
+
+
+ );
+ }
+
return (
{/* Provider Health Dashboard */}
@@ -324,6 +345,7 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
failoverThreshold={failoverThreshold}
healthPercent={provider.healthPercent}
status={provider.status}
+ onSelect={() => setSelectedProvider(provider.toolType)}
/>
))}
{healthProviders.length === 0 && (
diff --git a/src/renderer/hooks/useProviderDetail.ts b/src/renderer/hooks/useProviderDetail.ts
new file mode 100644
index 000000000..24b8d35ca
--- /dev/null
+++ b/src/renderer/hooks/useProviderDetail.ts
@@ -0,0 +1,389 @@
+/**
+ * useProviderDetail - Detailed data for a single provider's detail view
+ *
+ * Fetches per-provider usage stats, error breakdown, daily trends, hourly patterns,
+ * active sessions, and migration history for the ProviderDetailView component.
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import type { Session } from '../types';
+import type { ToolType, AgentErrorType } from '../../shared/types';
+import type { ProviderErrorStats } from '../../shared/account-types';
+import type { StatsTimeRange, StatsAggregation, QueryEvent } from '../../shared/stats-types';
+import type { ProviderUsageStats } from './useProviderHealth';
+import type { HealthStatus } from '../components/ProviderHealthCard';
+import { getAgentDisplayName } from '../services/contextGroomer';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ProviderDetail {
+ toolType: ToolType;
+ displayName: string;
+ available: boolean;
+ status: HealthStatus;
+
+ // Usage stats (for selected time range)
+ usage: ProviderUsageStats;
+
+ // Token breakdown (for detail table)
+ tokenBreakdown: {
+ inputTokens: number;
+ inputCostUsd: number;
+ outputTokens: number;
+ outputCostUsd: number;
+ cacheReadTokens: number;
+ cacheReadCostUsd: number;
+ cacheCreationTokens: number;
+ cacheCreationCostUsd: number;
+ };
+
+ // Quality / reliability
+ reliability: {
+ successRate: number;
+ errorRate: number;
+ totalErrors: number;
+ errorsByType: Partial
>;
+ avgResponseTimeMs: number;
+ p95ResponseTimeMs: number;
+ };
+
+ // Source split
+ queriesBySource: { user: number; auto: number };
+
+ // Location split
+ queriesByLocation: { local: number; remote: number };
+
+ // Trends (daily data points for charts)
+ dailyTrend: Array<{
+ date: string;
+ queryCount: number;
+ durationMs: number;
+ avgDurationMs: number;
+ }>;
+
+ // Hourly activity pattern (0-23)
+ hourlyPattern: Array<{
+ hour: number;
+ queryCount: number;
+ avgDurationMs: number;
+ }>;
+
+ // Active sessions using this provider
+ activeSessions: Array<{
+ id: string;
+ name: string;
+ projectRoot: string;
+ state: string;
+ }>;
+
+ // Migration history involving this provider
+ migrations: Array<{
+ timestamp: number;
+ sessionName: string;
+ direction: 'from' | 'to';
+ otherProvider: ToolType;
+ generation: number;
+ }>;
+}
+
+export interface UseProviderDetailResult {
+ detail: ProviderDetail | null;
+ isLoading: boolean;
+ refresh: () => void;
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+const EMPTY_USAGE: ProviderUsageStats = {
+ queryCount: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCacheReadTokens: 0,
+ totalCacheCreationTokens: 0,
+ totalCostUsd: 0,
+ totalDurationMs: 0,
+ avgDurationMs: 0,
+};
+
+function computeP95(durations: number[]): number {
+ if (durations.length === 0) return 0;
+ const sorted = [...durations].sort((a, b) => a - b);
+ const index = Math.floor(sorted.length * 0.95);
+ return sorted[Math.min(index, sorted.length - 1)];
+}
+
+// Rough cost estimation per token type (Claude Code default pricing)
+// These are approximations — actual costs vary by model
+const INPUT_COST_PER_TOKEN = 0.000003;
+const OUTPUT_COST_PER_TOKEN = 0.000015;
+const CACHE_READ_COST_PER_TOKEN = 0.0000003;
+const CACHE_CREATION_COST_PER_TOKEN = 0.00000375;
+
+function estimateTokenCosts(tokens: {
+ input: number;
+ output: number;
+ cacheRead: number;
+ cacheCreation: number;
+ totalCost: number;
+}): { inputCost: number; outputCost: number; cacheReadCost: number; cacheCreationCost: number } {
+ // If we have a total cost, distribute proportionally based on token counts
+ const rawInput = tokens.input * INPUT_COST_PER_TOKEN;
+ const rawOutput = tokens.output * OUTPUT_COST_PER_TOKEN;
+ const rawCacheRead = tokens.cacheRead * CACHE_READ_COST_PER_TOKEN;
+ const rawCacheCreation = tokens.cacheCreation * CACHE_CREATION_COST_PER_TOKEN;
+ const rawTotal = rawInput + rawOutput + rawCacheRead + rawCacheCreation;
+
+ if (rawTotal === 0 || tokens.totalCost === 0) {
+ return { inputCost: rawInput, outputCost: rawOutput, cacheReadCost: rawCacheRead, cacheCreationCost: rawCacheCreation };
+ }
+
+ // Scale to match actual total cost
+ const scale = tokens.totalCost / rawTotal;
+ return {
+ inputCost: rawInput * scale,
+ outputCost: rawOutput * scale,
+ cacheReadCost: rawCacheRead * scale,
+ cacheCreationCost: rawCacheCreation * scale,
+ };
+}
+
+// ============================================================================
+// Hook
+// ============================================================================
+
+export function useProviderDetail(
+ toolType: ToolType,
+ sessions: Session[],
+ timeRange: StatsTimeRange,
+): UseProviderDetailResult {
+ const [detail, setDetail] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const mountedRef = useRef(true);
+ const timeRangeRef = useRef(timeRange);
+ timeRangeRef.current = timeRange;
+
+ const refresh = useCallback(async () => {
+ try {
+ // Fetch all data in parallel
+ const [agents, errorStats, queryEvents, aggregation] = await Promise.all([
+ window.maestro.agents.detect() as Promise>,
+ window.maestro.providers.getErrorStats(toolType) as Promise,
+ window.maestro.stats.getStats(timeRangeRef.current, { agentType: toolType }) as Promise>,
+ window.maestro.stats.getAggregation(timeRangeRef.current) as Promise,
+ ]);
+
+ if (!mountedRef.current) return;
+
+ const agent = agents.find((a) => a.id === toolType);
+ const available = agent?.available ?? false;
+
+ // Aggregate usage stats
+ const usage: ProviderUsageStats = { ...EMPTY_USAGE };
+ const durations: number[] = [];
+ let userQueries = 0;
+ let autoQueries = 0;
+ let localQueries = 0;
+ let remoteQueries = 0;
+
+ for (const e of queryEvents) {
+ usage.queryCount += 1;
+ usage.totalInputTokens += (e as any).inputTokens ?? 0;
+ usage.totalOutputTokens += (e as any).outputTokens ?? 0;
+ usage.totalCacheReadTokens += (e as any).cacheReadTokens ?? 0;
+ usage.totalCacheCreationTokens += (e as any).cacheCreationTokens ?? 0;
+ usage.totalCostUsd += (e as any).costUsd ?? 0;
+ usage.totalDurationMs += e.duration ?? 0;
+ if (e.duration > 0) durations.push(e.duration);
+ if (e.source === 'user') userQueries++;
+ else autoQueries++;
+ if ((e as any).isRemote) remoteQueries++;
+ else localQueries++;
+ }
+ usage.avgDurationMs = usage.queryCount > 0
+ ? Math.round(usage.totalDurationMs / usage.queryCount)
+ : 0;
+
+ // Token cost breakdown
+ const costs = estimateTokenCosts({
+ input: usage.totalInputTokens,
+ output: usage.totalOutputTokens,
+ cacheRead: usage.totalCacheReadTokens,
+ cacheCreation: usage.totalCacheCreationTokens,
+ totalCost: usage.totalCostUsd,
+ });
+
+ // Error stats
+ const errorCount = errorStats?.totalErrorsInWindow ?? 0;
+ const totalQueries = usage.queryCount;
+ const successRate = totalQueries > 0
+ ? ((totalQueries - errorCount) / totalQueries) * 100
+ : 0;
+ const errorRate = totalQueries > 0
+ ? (errorCount / totalQueries) * 100
+ : 0;
+
+ // Determine status
+ let status: HealthStatus;
+ const activeCount = sessions.filter(
+ (s) => s.toolType === toolType && !s.archivedByMigration,
+ ).length;
+ if (!available) {
+ status = 'not_installed';
+ } else if (activeCount === 0) {
+ status = 'idle';
+ } else if (errorCount === 0) {
+ status = 'healthy';
+ } else if (errorCount >= 3) { // Use default threshold
+ status = 'failing';
+ } else {
+ status = 'degraded';
+ }
+
+ // Daily trend from aggregation
+ const dailyData = aggregation.byAgentByDay?.[toolType] ?? [];
+ const dailyTrend = dailyData.map((d) => ({
+ date: d.date,
+ queryCount: d.count,
+ durationMs: d.duration,
+ avgDurationMs: d.count > 0 ? Math.round(d.duration / d.count) : 0,
+ }));
+
+ // Hourly pattern — filter aggregation.byHour by this provider's events
+ // Since byHour doesn't include per-agent breakdown, compute from raw events
+ const hourlyMap = new Map();
+ for (let h = 0; h < 24; h++) {
+ hourlyMap.set(h, { count: 0, totalDuration: 0 });
+ }
+ for (const e of queryEvents) {
+ const hour = new Date(e.startTime).getHours();
+ const entry = hourlyMap.get(hour)!;
+ entry.count += 1;
+ entry.totalDuration += e.duration ?? 0;
+ }
+ const hourlyPattern = Array.from(hourlyMap.entries()).map(([hour, data]) => ({
+ hour,
+ queryCount: data.count,
+ avgDurationMs: data.count > 0 ? Math.round(data.totalDuration / data.count) : 0,
+ }));
+
+ // Active sessions
+ const activeSessions = sessions
+ .filter((s) => s.toolType === toolType && !s.archivedByMigration)
+ .map((s) => ({
+ id: s.id,
+ name: s.name || 'Unnamed Agent',
+ projectRoot: s.projectRoot,
+ state: s.state,
+ }));
+
+ // Migration history involving this provider
+ const migrations: ProviderDetail['migrations'] = [];
+ for (const s of sessions) {
+ if (s.migratedFromSessionId && s.migratedAt) {
+ const source = sessions.find((src) => src.id === s.migratedFromSessionId);
+ if (source) {
+ const sourceType = source.toolType as ToolType;
+ const targetType = s.toolType as ToolType;
+ if (sourceType === toolType) {
+ migrations.push({
+ timestamp: s.migratedAt,
+ sessionName: s.name || 'Unnamed Agent',
+ direction: 'from',
+ otherProvider: targetType,
+ generation: s.migrationGeneration || 1,
+ });
+ } else if (targetType === toolType) {
+ migrations.push({
+ timestamp: s.migratedAt,
+ sessionName: s.name || 'Unnamed Agent',
+ direction: 'to',
+ otherProvider: sourceType,
+ generation: s.migrationGeneration || 1,
+ });
+ }
+ }
+ }
+ }
+ migrations.sort((a, b) => b.timestamp - a.timestamp);
+
+ // P95 response time
+ const p95 = durations.length >= 20
+ ? computeP95(durations)
+ : usage.avgDurationMs;
+
+ const result: ProviderDetail = {
+ toolType,
+ displayName: getAgentDisplayName(toolType),
+ available,
+ status,
+ usage,
+ tokenBreakdown: {
+ inputTokens: usage.totalInputTokens,
+ inputCostUsd: costs.inputCost,
+ outputTokens: usage.totalOutputTokens,
+ outputCostUsd: costs.outputCost,
+ cacheReadTokens: usage.totalCacheReadTokens,
+ cacheReadCostUsd: costs.cacheReadCost,
+ cacheCreationTokens: usage.totalCacheCreationTokens,
+ cacheCreationCostUsd: costs.cacheCreationCost,
+ },
+ reliability: {
+ successRate,
+ errorRate,
+ totalErrors: errorCount,
+ errorsByType: {},
+ avgResponseTimeMs: usage.avgDurationMs,
+ p95ResponseTimeMs: p95,
+ },
+ queriesBySource: { user: userQueries, auto: autoQueries },
+ queriesByLocation: { local: localQueries, remote: remoteQueries },
+ dailyTrend,
+ hourlyPattern,
+ activeSessions,
+ migrations,
+ };
+
+ setDetail(result);
+ setIsLoading(false);
+ } catch (err) {
+ console.warn('[useProviderDetail] Failed to refresh:', err);
+ if (mountedRef.current) {
+ setIsLoading(false);
+ }
+ }
+ }, [toolType, sessions]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ setIsLoading(true);
+ refresh();
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [refresh]);
+
+ // Re-fetch when time range changes
+ useEffect(() => {
+ refresh();
+ }, [timeRange]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return { detail, isLoading, refresh };
+}
From c5a3b5bf731bee13522df7a21c7c67cf6a4870ed Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 18:58:19 -0500
Subject: [PATCH 47/59] MAESTRO: complete useProviderDetail hook with
errorsByType, config threshold, and type fixes
- Add errorsByType field to ProviderErrorStats and compute in ProviderErrorTracker
- Fix getStats return type in preload and global.d.ts to include token fields
- Replace hardcoded failover threshold with config-driven value from providerSwitchConfig
- Remove unnecessary (e as any) type casts in useProviderDetail
Co-Authored-By: Claude Opus 4.6
---
src/main/preload/stats.ts | 6 +++
src/main/providers/provider-error-tracker.ts | 6 +++
src/renderer/global.d.ts | 6 +++
src/renderer/hooks/useProviderDetail.ts | 52 ++++++++------------
src/shared/account-types.ts | 2 +
5 files changed, 41 insertions(+), 31 deletions(-)
diff --git a/src/main/preload/stats.ts b/src/main/preload/stats.ts
index 86f172f83..ec8cf2e2f 100644
--- a/src/main/preload/stats.ts
+++ b/src/main/preload/stats.ts
@@ -117,6 +117,12 @@ export function createStatsApi() {
duration: number;
projectPath?: string;
tabId?: string;
+ isRemote?: boolean;
+ inputTokens?: number;
+ outputTokens?: number;
+ cacheReadTokens?: number;
+ cacheCreationTokens?: number;
+ costUsd?: number;
}>
> => ipcRenderer.invoke('stats:get-stats', range, filters),
diff --git a/src/main/providers/provider-error-tracker.ts b/src/main/providers/provider-error-tracker.ts
index c1cff51f9..2bcb708c5 100644
--- a/src/main/providers/provider-error-tracker.ts
+++ b/src/main/providers/provider-error-tracker.ts
@@ -172,6 +172,7 @@ export class ProviderErrorTracker {
let totalErrorsInWindow = 0;
let lastErrorAt: number | null = null;
let sessionsWithErrors = 0;
+ const errorsByType: Partial> = {};
for (const state of this.sessions.values()) {
if (state.toolType !== toolType) continue;
@@ -186,6 +187,10 @@ export class ProviderErrorTracker {
if (lastErrorAt === null || latest > lastErrorAt) {
lastErrorAt = latest;
}
+ // Accumulate per-type counts
+ for (const err of active) {
+ errorsByType[err.errorType] = (errorsByType[err.errorType] ?? 0) + 1;
+ }
}
}
@@ -195,6 +200,7 @@ export class ProviderErrorTracker {
totalErrorsInWindow,
lastErrorAt,
sessionsWithErrors,
+ errorsByType,
};
}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 2d69d9afd..3f11ed2e8 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2191,6 +2191,12 @@ interface MaestroAPI {
duration: number;
projectPath?: string;
tabId?: string;
+ isRemote?: boolean;
+ inputTokens?: number;
+ outputTokens?: number;
+ cacheReadTokens?: number;
+ cacheCreationTokens?: number;
+ costUsd?: number;
}>
>;
// Get Auto Run sessions within a time range
diff --git a/src/renderer/hooks/useProviderDetail.ts b/src/renderer/hooks/useProviderDetail.ts
index 24b8d35ca..fb6203da4 100644
--- a/src/renderer/hooks/useProviderDetail.ts
+++ b/src/renderer/hooks/useProviderDetail.ts
@@ -8,7 +8,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { Session } from '../types';
import type { ToolType, AgentErrorType } from '../../shared/types';
-import type { ProviderErrorStats } from '../../shared/account-types';
+import type { ProviderErrorStats, ProviderSwitchConfig } from '../../shared/account-types';
+import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
import type { StatsTimeRange, StatsAggregation, QueryEvent } from '../../shared/stats-types';
import type { ProviderUsageStats } from './useProviderHealth';
import type { HealthStatus } from '../components/ProviderHealthCard';
@@ -168,34 +169,24 @@ export function useProviderDetail(
const refresh = useCallback(async () => {
try {
- // Fetch all data in parallel
- const [agents, errorStats, queryEvents, aggregation] = await Promise.all([
+ // Fetch all data in parallel — includes failover config for threshold
+ const [agents, errorStats, savedConfig, queryEvents, aggregation] = await Promise.all([
window.maestro.agents.detect() as Promise>,
window.maestro.providers.getErrorStats(toolType) as Promise,
- window.maestro.stats.getStats(timeRangeRef.current, { agentType: toolType }) as Promise>,
+ window.maestro.settings.get('providerSwitchConfig') as Promise | null>,
+ window.maestro.stats.getStats(timeRangeRef.current, { agentType: toolType }),
window.maestro.stats.getAggregation(timeRangeRef.current) as Promise,
]);
if (!mountedRef.current) return;
+ const threshold = (savedConfig as Partial)?.errorThreshold
+ ?? DEFAULT_PROVIDER_SWITCH_CONFIG.errorThreshold;
+
const agent = agents.find((a) => a.id === toolType);
const available = agent?.available ?? false;
- // Aggregate usage stats
+ // Aggregate usage stats from raw query events
const usage: ProviderUsageStats = { ...EMPTY_USAGE };
const durations: number[] = [];
let userQueries = 0;
@@ -205,16 +196,16 @@ export function useProviderDetail(
for (const e of queryEvents) {
usage.queryCount += 1;
- usage.totalInputTokens += (e as any).inputTokens ?? 0;
- usage.totalOutputTokens += (e as any).outputTokens ?? 0;
- usage.totalCacheReadTokens += (e as any).cacheReadTokens ?? 0;
- usage.totalCacheCreationTokens += (e as any).cacheCreationTokens ?? 0;
- usage.totalCostUsd += (e as any).costUsd ?? 0;
+ usage.totalInputTokens += e.inputTokens ?? 0;
+ usage.totalOutputTokens += e.outputTokens ?? 0;
+ usage.totalCacheReadTokens += e.cacheReadTokens ?? 0;
+ usage.totalCacheCreationTokens += e.cacheCreationTokens ?? 0;
+ usage.totalCostUsd += e.costUsd ?? 0;
usage.totalDurationMs += e.duration ?? 0;
if (e.duration > 0) durations.push(e.duration);
if (e.source === 'user') userQueries++;
else autoQueries++;
- if ((e as any).isRemote) remoteQueries++;
+ if (e.isRemote) remoteQueries++;
else localQueries++;
}
usage.avgDurationMs = usage.queryCount > 0
@@ -240,7 +231,7 @@ export function useProviderDetail(
? (errorCount / totalQueries) * 100
: 0;
- // Determine status
+ // Determine status using config-driven threshold
let status: HealthStatus;
const activeCount = sessions.filter(
(s) => s.toolType === toolType && !s.archivedByMigration,
@@ -251,7 +242,7 @@ export function useProviderDetail(
status = 'idle';
} else if (errorCount === 0) {
status = 'healthy';
- } else if (errorCount >= 3) { // Use default threshold
+ } else if (errorCount >= threshold) {
status = 'failing';
} else {
status = 'degraded';
@@ -266,8 +257,7 @@ export function useProviderDetail(
avgDurationMs: d.count > 0 ? Math.round(d.duration / d.count) : 0,
}));
- // Hourly pattern — filter aggregation.byHour by this provider's events
- // Since byHour doesn't include per-agent breakdown, compute from raw events
+ // Hourly pattern — compute from raw events (byHour lacks per-agent breakdown)
const hourlyMap = new Map();
for (let h = 0; h < 24; h++) {
hourlyMap.set(h, { count: 0, totalDuration: 0 });
@@ -324,7 +314,7 @@ export function useProviderDetail(
}
migrations.sort((a, b) => b.timestamp - a.timestamp);
- // P95 response time
+ // P95 response time — only meaningful with >= 20 data points
const p95 = durations.length >= 20
? computeP95(durations)
: usage.avgDurationMs;
@@ -349,7 +339,7 @@ export function useProviderDetail(
successRate,
errorRate,
totalErrors: errorCount,
- errorsByType: {},
+ errorsByType: errorStats?.errorsByType ?? {},
avgResponseTimeMs: usage.avgDurationMs,
p95ResponseTimeMs: p95,
},
diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts
index 64abf67f9..938199f53 100644
--- a/src/shared/account-types.ts
+++ b/src/shared/account-types.ts
@@ -190,4 +190,6 @@ export interface ProviderErrorStats {
totalErrorsInWindow: number;
lastErrorAt: number | null;
sessionsWithErrors: number;
+ /** Error count breakdown by error type within the window */
+ errorsByType?: Partial>;
}
From 7dff7f9e2b1b597395590c6a8bd346b37a425e13 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 19:03:35 -0500
Subject: [PATCH 48/59] MAESTRO: add Provider Detail Charts with 6
visualization panels
Add ProviderDetailCharts.tsx with query volume trend, response time trend
(with p95 band), 24-hour activity heatmap, token breakdown stacked bar,
source/location donut charts, and reliability gauge with error type badges.
Integrated into ProviderDetailView below summary metrics.
Co-Authored-By: Claude Opus 4.6
---
.../components/ProviderDetailCharts.tsx | 1183 +++++++++++++++++
.../components/ProviderDetailView.tsx | 6 +
2 files changed, 1189 insertions(+)
create mode 100644 src/renderer/components/ProviderDetailCharts.tsx
diff --git a/src/renderer/components/ProviderDetailCharts.tsx b/src/renderer/components/ProviderDetailCharts.tsx
new file mode 100644
index 000000000..93a5acc64
--- /dev/null
+++ b/src/renderer/components/ProviderDetailCharts.tsx
@@ -0,0 +1,1183 @@
+/**
+ * ProviderDetailCharts - Charts and visualizations for the provider detail view
+ *
+ * Renders inside ProviderDetailView below the summary stats.
+ * Layout: 2-column grid of chart panels.
+ *
+ * Charts:
+ * 1. Query Volume Trend (SVG line chart)
+ * 2. Response Time Trend (SVG line chart with p95 band)
+ * 3. Activity Heatmap (24-hour bar chart)
+ * 4. Token Breakdown (horizontal stacked bar)
+ * 5. Source & Location Split (two donut charts)
+ * 6. Reliability Score (gauge metric card)
+ */
+
+import React, { useState, useMemo, useCallback } from 'react';
+import type { Theme } from '../types';
+import type { AgentErrorType } from '../../shared/types';
+import type { ProviderDetail } from '../hooks/useProviderDetail';
+import { formatTokenCount } from '../hooks/useAccountUsage';
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ProviderDetailChartsProps {
+ theme: Theme;
+ detail: ProviderDetail;
+}
+
+// ============================================================================
+// Shared helpers
+// ============================================================================
+
+function formatDurationMs(ms: number): string {
+ if (ms === 0) return '0s';
+ if (ms < 1000) return `${Math.round(ms)}ms`;
+ const s = ms / 1000;
+ if (s < 60) return `${s.toFixed(1)}s`;
+ const m = Math.floor(s / 60);
+ const rem = Math.round(s % 60);
+ return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
+}
+
+function formatHour(hour: number): string {
+ if (hour === 0) return '12am';
+ if (hour === 12) return '12pm';
+ if (hour < 12) return `${hour}am`;
+ return `${hour - 12}pm`;
+}
+
+function parseHexColor(hex: string): { r: number; g: number; b: number } {
+ const clean = hex.startsWith('#') ? hex.slice(1) : hex;
+ return {
+ r: parseInt(clean.slice(0, 2), 16) || 100,
+ g: parseInt(clean.slice(2, 4), 16) || 149,
+ b: parseInt(clean.slice(4, 6), 16) || 237,
+ };
+}
+
+const ERROR_TYPE_LABELS: Record = {
+ auth_expired: 'Auth Expired',
+ token_exhaustion: 'Token Exhaustion',
+ rate_limited: 'Rate Limited',
+ network_error: 'Network Error',
+ agent_crashed: 'Agent Crashed',
+ permission_denied: 'Permission Denied',
+ session_not_found: 'Session Not Found',
+ unknown: 'Unknown',
+};
+
+// ============================================================================
+// Chart wrapper
+// ============================================================================
+
+function ChartPanel({
+ theme,
+ title,
+ children,
+}: {
+ theme: Theme;
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+// ============================================================================
+// Chart 1: Query Volume Trend (SVG line chart)
+// ============================================================================
+
+function QueryVolumeTrendChart({
+ theme,
+ dailyTrend,
+}: {
+ theme: Theme;
+ dailyTrend: ProviderDetail['dailyTrend'];
+}) {
+ const [hoveredIdx, setHoveredIdx] = useState(null);
+ const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
+
+ const chartWidth = 500;
+ const chartHeight = 140;
+ const pad = { top: 14, right: 20, bottom: 28, left: 40 };
+ const innerW = chartWidth - pad.left - pad.right;
+ const innerH = chartHeight - pad.top - pad.bottom;
+
+ const maxVal = useMemo(
+ () => Math.max(...dailyTrend.map((d) => d.queryCount), 1),
+ [dailyTrend],
+ );
+
+ const xScale = useCallback(
+ (i: number) => pad.left + (i / Math.max(dailyTrend.length - 1, 1)) * innerW,
+ [dailyTrend.length, innerW, pad.left],
+ );
+
+ const yScale = useCallback(
+ (v: number) => chartHeight - pad.bottom - (v / (maxVal * 1.1)) * innerH,
+ [maxVal, innerH, chartHeight, pad.bottom],
+ );
+
+ const linePath = useMemo(() => {
+ if (dailyTrend.length === 0) return '';
+ return dailyTrend
+ .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.queryCount)}`)
+ .join(' ');
+ }, [dailyTrend, xScale, yScale]);
+
+ const areaPath = useMemo(() => {
+ if (dailyTrend.length === 0) return '';
+ const line = dailyTrend
+ .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.queryCount)}`)
+ .join(' ');
+ const lastX = xScale(dailyTrend.length - 1);
+ const firstX = xScale(0);
+ const baseline = chartHeight - pad.bottom;
+ return `${line} L ${lastX} ${baseline} L ${firstX} ${baseline} Z`;
+ }, [dailyTrend, xScale, yScale, chartHeight, pad.bottom]);
+
+ const accent = parseHexColor(theme.colors.accent);
+ const gradientId = 'qvt-grad';
+
+ // Y-axis ticks
+ const yTicks = useMemo(() => {
+ const tickCount = 4;
+ const yMax = maxVal * 1.1;
+ return Array.from({ length: tickCount }, (_, i) => Math.round((yMax / (tickCount - 1)) * i));
+ }, [maxVal]);
+
+ // X-axis labels — show max 7
+ const xLabelInterval = useMemo(
+ () => Math.max(1, Math.ceil(dailyTrend.length / 7)),
+ [dailyTrend.length],
+ );
+
+ if (dailyTrend.length === 0) {
+ return (
+
+ No trend data available
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {/* Grid lines + Y labels */}
+ {yTicks.map((tick, i) => (
+
+
+
+ {tick}
+
+
+ ))}
+
+ {/* X labels */}
+ {dailyTrend.map((d, i) => {
+ if (i % xLabelInterval !== 0 && i !== dailyTrend.length - 1) return null;
+ const label = d.date.slice(5); // MM-DD
+ return (
+
+ {label}
+
+ );
+ })}
+
+ {/* Area fill */}
+
+
+ {/* Line */}
+
+
+ {/* Data points */}
+ {dailyTrend.map((d, i) => {
+ const isHovered = hoveredIdx === i;
+ return (
+ {
+ setHoveredIdx(i);
+ const rect = e.currentTarget.getBoundingClientRect();
+ setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
+ }}
+ onMouseLeave={() => {
+ setHoveredIdx(null);
+ setTooltipPos(null);
+ }}
+ />
+ );
+ })}
+
+
+ {/* Tooltip */}
+ {hoveredIdx !== null && tooltipPos && (
+
+
{dailyTrend[hoveredIdx].date}
+
+ {dailyTrend[hoveredIdx].queryCount} queries
+
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Chart 2: Response Time Trend (SVG line chart with p95 band)
+// ============================================================================
+
+function ResponseTimeTrendChart({
+ theme,
+ dailyTrend,
+ p95ResponseTimeMs,
+}: {
+ theme: Theme;
+ dailyTrend: ProviderDetail['dailyTrend'];
+ p95ResponseTimeMs: number;
+}) {
+ const [hoveredIdx, setHoveredIdx] = useState(null);
+ const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
+
+ const chartWidth = 500;
+ const chartHeight = 140;
+ const pad = { top: 14, right: 20, bottom: 28, left: 44 };
+ const innerW = chartWidth - pad.left - pad.right;
+ const innerH = chartHeight - pad.top - pad.bottom;
+
+ const maxVal = useMemo(() => {
+ const maxAvg = Math.max(...dailyTrend.map((d) => d.avgDurationMs), 1);
+ return Math.max(maxAvg, p95ResponseTimeMs) * 1.1;
+ }, [dailyTrend, p95ResponseTimeMs]);
+
+ const xScale = useCallback(
+ (i: number) => pad.left + (i / Math.max(dailyTrend.length - 1, 1)) * innerW,
+ [dailyTrend.length, innerW, pad.left],
+ );
+
+ const yScale = useCallback(
+ (v: number) => chartHeight - pad.bottom - (v / maxVal) * innerH,
+ [maxVal, innerH, chartHeight, pad.bottom],
+ );
+
+ const linePath = useMemo(() => {
+ if (dailyTrend.length === 0) return '';
+ return dailyTrend
+ .map((d, i) => `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(d.avgDurationMs)}`)
+ .join(' ');
+ }, [dailyTrend, xScale, yScale]);
+
+ // Y-axis ticks
+ const yTicks = useMemo(() => {
+ const tickCount = 4;
+ return Array.from({ length: tickCount }, (_, i) => Math.round((maxVal / (tickCount - 1)) * i));
+ }, [maxVal]);
+
+ const xLabelInterval = useMemo(
+ () => Math.max(1, Math.ceil(dailyTrend.length / 7)),
+ [dailyTrend.length],
+ );
+
+ if (dailyTrend.length === 0) {
+ return (
+
+ No response time data available
+
+ );
+ }
+
+ return (
+
+
+ {/* Grid lines + Y labels */}
+ {yTicks.map((tick, i) => (
+
+
+
+ {formatDurationMs(tick)}
+
+
+ ))}
+
+ {/* X labels */}
+ {dailyTrend.map((d, i) => {
+ if (i % xLabelInterval !== 0 && i !== dailyTrend.length - 1) return null;
+ return (
+
+ {d.date.slice(5)}
+
+ );
+ })}
+
+ {/* P95 reference band */}
+ {p95ResponseTimeMs > 0 && (
+ <>
+
+
+ p95
+
+ >
+ )}
+
+ {/* Line */}
+
+
+ {/* Data points */}
+ {dailyTrend.map((d, i) => {
+ const isHovered = hoveredIdx === i;
+ return (
+ {
+ setHoveredIdx(i);
+ const rect = e.currentTarget.getBoundingClientRect();
+ setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top });
+ }}
+ onMouseLeave={() => {
+ setHoveredIdx(null);
+ setTooltipPos(null);
+ }}
+ />
+ );
+ })}
+
+
+ {/* Tooltip */}
+ {hoveredIdx !== null && tooltipPos && (
+
+
{dailyTrend[hoveredIdx].date}
+
+ Avg: {formatDurationMs(dailyTrend[hoveredIdx].avgDurationMs)}
+
+
+ )}
+
+ {/* Legend */}
+
+
+ {p95ResponseTimeMs > 0 && (
+
+
+
P95 ({formatDurationMs(p95ResponseTimeMs)})
+
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// Chart 3: Activity Heatmap (24-hour bar chart)
+// ============================================================================
+
+function ActivityHoursChart({
+ theme,
+ hourlyPattern,
+}: {
+ theme: Theme;
+ hourlyPattern: ProviderDetail['hourlyPattern'];
+}) {
+ const [hoveredHour, setHoveredHour] = useState(null);
+
+ const maxCount = useMemo(
+ () => Math.max(...hourlyPattern.map((h) => h.queryCount), 1),
+ [hourlyPattern],
+ );
+
+ const peakHour = useMemo(() => {
+ let peak = { hour: 0, count: 0 };
+ for (const h of hourlyPattern) {
+ if (h.queryCount > peak.count) {
+ peak = { hour: h.hour, count: h.queryCount };
+ }
+ }
+ return peak.hour;
+ }, [hourlyPattern]);
+
+ const hasData = hourlyPattern.some((h) => h.queryCount > 0);
+ const chartHeight = 100;
+
+ if (!hasData) {
+ return (
+
+ No hourly data available
+
+ );
+ }
+
+ return (
+
+ {/* Bars */}
+
+ {hourlyPattern.map((h) => {
+ const height = maxCount > 0 ? (h.queryCount / maxCount) * 100 : 0;
+ const isPeak = h.hour === peakHour && h.queryCount > 0;
+ const isHovered = hoveredHour === h.hour;
+
+ return (
+
setHoveredHour(h.hour)}
+ onMouseLeave={() => setHoveredHour(null)}
+ >
+
0 ? 2 : 0)}%`,
+ backgroundColor: isPeak
+ ? theme.colors.accent
+ : isHovered
+ ? `${theme.colors.accent}90`
+ : `${theme.colors.accent}50`,
+ transform: isHovered ? 'scaleY(1.05)' : 'scaleY(1)',
+ transformOrigin: 'bottom',
+ }}
+ />
+
+ {/* Tooltip */}
+ {isHovered && h.queryCount > 0 && (
+
+
{formatHour(h.hour)}
+
+ {h.queryCount} queries
+
+ {h.avgDurationMs > 0 && (
+
+ Avg: {formatDurationMs(h.avgDurationMs)}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+
+ {/* X-axis labels (every 4 hours) */}
+
+ {[0, 4, 8, 12, 16, 20].map((hour) => (
+
+ {formatHour(hour)}
+
+ ))}
+
+
+ {/* Peak indicator */}
+
+ Peak:
+
+ {formatHour(peakHour)}
+
+
+
+ );
+}
+
+// ============================================================================
+// Chart 4: Token Breakdown (horizontal stacked bar)
+// ============================================================================
+
+function TokenBreakdownChart({
+ theme,
+ tokenBreakdown,
+}: {
+ theme: Theme;
+ tokenBreakdown: ProviderDetail['tokenBreakdown'];
+}) {
+ const segments = useMemo(() => {
+ const items = [
+ {
+ label: 'Input',
+ tokens: tokenBreakdown.inputTokens,
+ cost: tokenBreakdown.inputCostUsd,
+ color: theme.colors.accent,
+ },
+ {
+ label: 'Output',
+ tokens: tokenBreakdown.outputTokens,
+ cost: tokenBreakdown.outputCostUsd,
+ color: theme.colors.success,
+ },
+ {
+ label: 'Cache Read',
+ tokens: tokenBreakdown.cacheReadTokens,
+ cost: tokenBreakdown.cacheReadCostUsd,
+ color: theme.colors.warning,
+ },
+ {
+ label: 'Cache Write',
+ tokens: tokenBreakdown.cacheCreationTokens,
+ cost: tokenBreakdown.cacheCreationCostUsd,
+ color: '#8b5cf6', // purple
+ },
+ ];
+
+ const totalTokens = items.reduce((sum, s) => sum + s.tokens, 0);
+ return items.map((s) => ({
+ ...s,
+ percent: totalTokens > 0 ? (s.tokens / totalTokens) * 100 : 0,
+ }));
+ }, [tokenBreakdown, theme]);
+
+ const totalTokens = segments.reduce((sum, s) => sum + s.tokens, 0);
+
+ if (totalTokens === 0) {
+ return (
+
+ No token data available
+
+ );
+ }
+
+ return (
+
+ {/* Stacked bar */}
+
+ {segments.map((seg) =>
+ seg.percent > 0 ? (
+
0 ? 2 : 0,
+ }}
+ title={`${seg.label}: ${formatTokenCount(seg.tokens)} (${seg.percent.toFixed(1)}%)`}
+ />
+ ) : null,
+ )}
+
+
+ {/* Legend with costs */}
+
+ {segments.map((seg) => (
+
+
+
+
+ {seg.label}:
+ {' '}
+
+ {formatTokenCount(seg.tokens)}
+
+ {seg.cost > 0 && (
+
+ {' '}(${seg.cost.toFixed(2)})
+
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+// ============================================================================
+// Chart 5: Source & Location Split (two donut charts)
+// ============================================================================
+
+function DonutChart({
+ theme,
+ slices,
+ size = 64,
+}: {
+ theme: Theme;
+ slices: Array<{ label: string; value: number; color: string }>;
+ size?: number;
+}) {
+ const total = slices.reduce((sum, s) => sum + s.value, 0);
+ const radius = size / 2 - 4;
+ const innerRadius = radius * 0.55;
+ const cx = size / 2;
+ const cy = size / 2;
+
+ if (total === 0) {
+ return (
+
+
+
+ );
+ }
+
+ let currentAngle = -Math.PI / 2; // Start at top
+ const arcs = slices.map((slice) => {
+ const angle = (slice.value / total) * Math.PI * 2;
+ const startAngle = currentAngle;
+ const endAngle = currentAngle + angle;
+ currentAngle = endAngle;
+
+ const largeArcFlag = angle > Math.PI ? 1 : 0;
+ const midRadius = (radius + innerRadius) / 2;
+ const thickness = radius - innerRadius;
+
+ const x1 = cx + midRadius * Math.cos(startAngle);
+ const y1 = cy + midRadius * Math.sin(startAngle);
+ const x2 = cx + midRadius * Math.cos(endAngle);
+ const y2 = cy + midRadius * Math.sin(endAngle);
+
+ // For a full circle (100%), use two arcs
+ if (angle >= Math.PI * 2 - 0.01) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ });
+
+ return (
+
+ {arcs}
+
+ );
+}
+
+function SourceLocationSplitChart({
+ theme,
+ queriesBySource,
+ queriesByLocation,
+}: {
+ theme: Theme;
+ queriesBySource: ProviderDetail['queriesBySource'];
+ queriesByLocation: ProviderDetail['queriesByLocation'];
+}) {
+ const sourceTotal = queriesBySource.user + queriesBySource.auto;
+ const locationTotal = queriesByLocation.local + queriesByLocation.remote;
+
+ const noData = sourceTotal === 0 && locationTotal === 0;
+
+ if (noData) {
+ return (
+
+ No query data available
+
+ );
+ }
+
+ return (
+
+ {/* Source split */}
+
+
+
+
+
+ {queriesBySource.user} user
+
+
+
+ {queriesBySource.auto} auto
+
+
+
+
+ {/* Location split */}
+
+
+
+
+
+ {queriesByLocation.local} local
+
+
+
+ {queriesByLocation.remote} remote
+
+
+
+
+ );
+}
+
+// ============================================================================
+// Chart 6: Reliability Score (gauge metric card)
+// ============================================================================
+
+function ReliabilityGaugeChart({
+ theme,
+ reliability,
+ queryCount,
+}: {
+ theme: Theme;
+ reliability: ProviderDetail['reliability'];
+ queryCount: number;
+}) {
+ const gaugeColor = useMemo(() => {
+ if (queryCount === 0) return theme.colors.textDim;
+ if (reliability.successRate >= 95) return theme.colors.success;
+ if (reliability.successRate >= 85) return theme.colors.warning;
+ return theme.colors.error;
+ }, [reliability.successRate, queryCount, theme]);
+
+ const displayRate = queryCount > 0 ? `${reliability.successRate.toFixed(1)}%` : 'N/A';
+
+ // Error type breakdown
+ const errorEntries = useMemo(() => {
+ const entries: Array<{ type: AgentErrorType; count: number }> = [];
+ for (const [type, count] of Object.entries(reliability.errorsByType)) {
+ if (count && count > 0) {
+ entries.push({ type: type as AgentErrorType, count });
+ }
+ }
+ return entries.sort((a, b) => b.count - a.count);
+ }, [reliability.errorsByType]);
+
+ // SVG gauge arc
+ const gaugeSize = 80;
+ const gaugeRadius = 32;
+ const gaugeThickness = 6;
+ const cx = gaugeSize / 2;
+ const cy = gaugeSize / 2 + 4;
+ const startAngle = -Math.PI * 0.8;
+ const endAngle = Math.PI * 0.8;
+ const totalArc = endAngle - startAngle;
+ const filledAngle = startAngle + (queryCount > 0 ? (reliability.successRate / 100) : 0) * totalArc;
+
+ const bgArcStart = {
+ x: cx + gaugeRadius * Math.cos(startAngle),
+ y: cy + gaugeRadius * Math.sin(startAngle),
+ };
+ const bgArcEnd = {
+ x: cx + gaugeRadius * Math.cos(endAngle),
+ y: cy + gaugeRadius * Math.sin(endAngle),
+ };
+ const filledArcEnd = {
+ x: cx + gaugeRadius * Math.cos(filledAngle),
+ y: cy + gaugeRadius * Math.sin(filledAngle),
+ };
+ const largeArc = totalArc > Math.PI ? 1 : 0;
+ const filledLargeArc = (filledAngle - startAngle) > Math.PI ? 1 : 0;
+
+ return (
+
+ {/* Gauge */}
+
+
+ {/* Background arc */}
+
+ {/* Filled arc */}
+ {queryCount > 0 && reliability.successRate > 0 && (
+
+ )}
+ {/* Center text */}
+
+ {displayRate}
+
+
+ reliable
+
+
+
+
+ {/* Error breakdown */}
+ {errorEntries.length > 0 && (
+
+ {errorEntries.map((e) => (
+
+ {ERROR_TYPE_LABELS[e.type] ?? e.type}: {e.count}
+
+ ))}
+
+ )}
+
+ {/* Zero errors note */}
+ {reliability.totalErrors === 0 && queryCount > 0 && (
+
+ No errors recorded
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Main component
+// ============================================================================
+
+export function ProviderDetailCharts({ theme, detail }: ProviderDetailChartsProps) {
+ return (
+
+ {/* Chart 1: Query Volume Trend */}
+
+
+
+
+ {/* Chart 2: Response Time Trend */}
+
+
+
+
+ {/* Chart 3: Activity Heatmap */}
+
+
+
+
+ {/* Chart 4: Token Breakdown */}
+
+
+
+
+ {/* Chart 5: Source & Location Split */}
+
+
+
+
+ {/* Chart 6: Reliability Score */}
+
+
+
+
+ );
+}
+
+export default ProviderDetailCharts;
diff --git a/src/renderer/components/ProviderDetailView.tsx b/src/renderer/components/ProviderDetailView.tsx
index b4ce41b35..b7d9ba728 100644
--- a/src/renderer/components/ProviderDetailView.tsx
+++ b/src/renderer/components/ProviderDetailView.tsx
@@ -14,6 +14,7 @@ import { getAgentIcon } from '../constants/agentIcons';
import { formatTokenCount } from '../hooks/useAccountUsage';
import { useProviderDetail } from '../hooks/useProviderDetail';
import type { HealthStatus } from './ProviderHealthCard';
+import { ProviderDetailCharts } from './ProviderDetailCharts';
import { getAgentDisplayName } from '../services/contextGroomer';
// ============================================================================
@@ -335,6 +336,11 @@ export function ProviderDetailView({
/>
+ {/* Charts and visualizations */}
+
+
{/* Active Sessions */}
{detail.activeSessions.length > 0 && (
Date: Thu, 19 Feb 2026 19:10:55 -0500
Subject: [PATCH 49/59] MAESTRO: add comparison benchmarks, clickable sessions,
and migration timeline to Provider Detail View
Adds ComparisonBar with query/cost share progress bars and response time/reliability
rankings across providers, enhances Active Sessions list with click-to-navigate
(closes modal and sets active session), and extracts MigrationTimeline as a dedicated
sub-component with ArrowRightLeft icons and generation badges.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/App.tsx | 4 +
.../components/ProviderDetailView.tsx | 420 +++++++++++++-----
src/renderer/components/ProviderPanel.tsx | 4 +-
src/renderer/components/VirtuososModal.tsx | 5 +-
src/renderer/hooks/useProviderDetail.ts | 74 ++-
5 files changed, 398 insertions(+), 109 deletions(-)
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 784fca0bf..d0a272c22 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -9713,6 +9713,10 @@ You are taking over this conversation. Based on the context above, provide a bri
onClose={() => setVirtuososOpen(false)}
theme={theme}
sessions={sessions}
+ onSelectSession={(sessionId) => {
+ setVirtuososOpen(false);
+ setActiveSessionId(sessionId);
+ }}
/>
)}
diff --git a/src/renderer/components/ProviderDetailView.tsx b/src/renderer/components/ProviderDetailView.tsx
index b7d9ba728..cf6c32ace 100644
--- a/src/renderer/components/ProviderDetailView.tsx
+++ b/src/renderer/components/ProviderDetailView.tsx
@@ -12,7 +12,7 @@ import type { ToolType } from '../../shared/types';
import type { StatsTimeRange } from '../../shared/stats-types';
import { getAgentIcon } from '../constants/agentIcons';
import { formatTokenCount } from '../hooks/useAccountUsage';
-import { useProviderDetail } from '../hooks/useProviderDetail';
+import { useProviderDetail, type ProviderDetail } from '../hooks/useProviderDetail';
import type { HealthStatus } from './ProviderHealthCard';
import { ProviderDetailCharts } from './ProviderDetailCharts';
import { getAgentDisplayName } from '../services/contextGroomer';
@@ -28,6 +28,7 @@ interface ProviderDetailViewProps {
timeRange: StatsTimeRange;
setTimeRange: (range: StatsTimeRange) => void;
onBack: () => void;
+ onSelectSession?: (sessionId: string) => void;
}
// ============================================================================
@@ -92,6 +93,7 @@ export function ProviderDetailView({
timeRange,
setTimeRange,
onBack,
+ onSelectSession,
}: ProviderDetailViewProps) {
const { detail, isLoading } = useProviderDetail(toolType, sessions, timeRange);
@@ -341,112 +343,21 @@ export function ProviderDetailView({
+ {/* Comparison Bar */}
+
+
{/* Active Sessions */}
- {detail.activeSessions.length > 0 && (
-
-
- Active Sessions ({detail.activeSessions.length})
-
- {detail.activeSessions.map((s) => (
-
-
- {s.name}
-
-
- {s.projectRoot}
-
-
- ● {s.state}
-
-
- ))}
-
- )}
+
{/* Migration History */}
- {detail.migrations.length > 0 && (
-
-
- Migration History
-
- {detail.migrations.slice(0, 10).map((m, i) => (
-
-
- {formatMigrationTime(m.timestamp)}
-
-
- {m.sessionName}:
-
-
- {m.direction === 'from' ? '→' : '←'}{' '}
- {m.direction === 'from' ? 'Switched TO' : 'Switched FROM'}{' '}
- {getAgentDisplayName(m.otherProvider)}
-
-
- ))}
-
- )}
+
);
}
@@ -491,4 +402,305 @@ function MetricCard({
);
}
+// ============================================================================
+// ComparisonBar — benchmarks vs other providers
+// ============================================================================
+
+function ComparisonBar({
+ theme,
+ detail,
+}: {
+ theme: Theme;
+ detail: ProviderDetail;
+}) {
+ const { comparison } = detail;
+ if (comparison.totalQueriesAllProviders === 0) return null;
+
+ const fastest = comparison.avgResponseRanking[0];
+ const slowest = comparison.avgResponseRanking[comparison.avgResponseRanking.length - 1];
+ const highestReliability = comparison.reliabilityRanking[0];
+ const lowestReliability = comparison.reliabilityRanking[comparison.reliabilityRanking.length - 1];
+
+ return (
+
+
+ How does {detail.displayName} compare?
+
+
+ {/* Query share progress bar */}
+
+
+ {/* Cost share progress bar */}
+
+
+ {/* Avg Response Time comparison */}
+ {comparison.avgResponseRanking.length > 1 && fastest && slowest && (
+
+ Avg Response
+
+ {formatDurationMs(detail.reliability.avgResponseTimeMs)}
+
+
+ (fastest: {fastest.provider} {formatDurationMs(fastest.avgMs)},
+ slowest: {slowest.provider} {formatDurationMs(slowest.avgMs)})
+
+
+ )}
+
+ {/* Reliability comparison */}
+ {comparison.reliabilityRanking.length > 1 && highestReliability && lowestReliability && (
+
+ Reliability
+ = 95
+ ? theme.colors.success
+ : detail.reliability.successRate >= 85
+ ? theme.colors.warning
+ : theme.colors.error,
+ }}
+ >
+ {detail.usage.queryCount > 0 ? `${detail.reliability.successRate.toFixed(1)}%` : 'N/A'}
+
+
+ (highest: {highestReliability.provider} {highestReliability.rate.toFixed(1)}%,
+ lowest: {lowestReliability.provider} {lowestReliability.rate.toFixed(1)}%)
+
+
+ )}
+
+ );
+}
+
+function ComparisonRow({
+ theme,
+ label,
+ percent,
+ detail,
+}: {
+ theme: Theme;
+ label: string;
+ percent: number;
+ detail: string;
+}) {
+ return (
+
+
+ {label}
+ {detail}
+
+
+
+ );
+}
+
+// ============================================================================
+// ActiveSessionsList — clickable sessions that navigate to the session
+// ============================================================================
+
+function ActiveSessionsList({
+ theme,
+ sessions,
+ onSelectSession,
+}: {
+ theme: Theme;
+ sessions: Array<{ id: string; name: string; projectRoot: string; state: string }>;
+ onSelectSession?: (sessionId: string) => void;
+}) {
+ if (sessions.length === 0) return null;
+
+ return (
+
+
+ Active Sessions ({sessions.length})
+
+ {sessions.map((s) => (
+
onSelectSession?.(s.id)}
+ onMouseEnter={(e) => {
+ if (onSelectSession) {
+ e.currentTarget.style.backgroundColor = `${theme.colors.accent}10`;
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ }}
+ >
+
+ {s.name}
+
+
+ {s.projectRoot}
+
+
+ ● {s.state}
+
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// MigrationTimeline — provider-scoped migration history
+// ============================================================================
+
+function MigrationTimeline({
+ theme,
+ migrations,
+}: {
+ theme: Theme;
+ migrations: Array<{
+ timestamp: number;
+ sessionName: string;
+ direction: 'from' | 'to';
+ otherProvider: ToolType;
+ generation: number;
+ }>;
+}) {
+ if (migrations.length === 0) return null;
+
+ return (
+
+
+ Migration History
+
+ {migrations.slice(0, 10).map((m, i) => (
+
+
+ {formatMigrationTime(m.timestamp)}
+
+
+ {m.sessionName}:
+
+
+
+ {' '}
+ {m.direction === 'from' ? 'Switched TO' : 'Switched FROM'}{' '}
+ {getAgentDisplayName(m.otherProvider)}
+
+ {m.generation > 1 && (
+
+ (gen {m.generation})
+
+ )}
+
+ ))}
+
+ );
+}
+
export default ProviderDetailView;
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
index 812d9020a..04166148b 100644
--- a/src/renderer/components/ProviderPanel.tsx
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -35,6 +35,7 @@ import { formatTokenCount } from '../hooks/useAccountUsage';
interface ProviderPanelProps {
theme: Theme;
sessions?: Session[];
+ onSelectSession?: (sessionId: string) => void;
}
interface MigrationEntry {
@@ -102,7 +103,7 @@ function ordinalSuffix(n: number): string {
// Component
// ============================================================================
-export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
+export function ProviderPanel({ theme, sessions = [], onSelectSession }: ProviderPanelProps) {
const {
providers: healthProviders,
isLoading: healthLoading,
@@ -235,6 +236,7 @@ export function ProviderPanel({ theme, sessions = [] }: ProviderPanelProps) {
timeRange={timeRange}
setTimeRange={setTimeRange}
onBack={() => setSelectedProvider(null)}
+ onSelectSession={onSelectSession}
/>
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
index 295eb266c..aacfa9abb 100644
--- a/src/renderer/components/VirtuososModal.tsx
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -30,9 +30,10 @@ interface VirtuososModalProps {
onClose: () => void;
theme: Theme;
sessions?: Session[];
+ onSelectSession?: (sessionId: string) => void;
}
-export function VirtuososModal({ isOpen, onClose, theme, sessions }: VirtuososModalProps) {
+export function VirtuososModal({ isOpen, onClose, theme, sessions, onSelectSession }: VirtuososModalProps) {
const [activeTab, setActiveTab] = useState
('config');
const { hasDegradedProvider, hasFailingProvider } = useProviderHealth(sessions);
@@ -139,7 +140,7 @@ export function VirtuososModal({ isOpen, onClose, theme, sessions }: VirtuososMo
{/* Tab content */}
{activeTab === 'config' && }
- {activeTab === 'providers' && }
+ {activeTab === 'providers' && }
{activeTab === 'usage' && }
);
diff --git a/src/renderer/hooks/useProviderDetail.ts b/src/renderer/hooks/useProviderDetail.ts
index fb6203da4..d60cb12ee 100644
--- a/src/renderer/hooks/useProviderDetail.ts
+++ b/src/renderer/hooks/useProviderDetail.ts
@@ -87,6 +87,16 @@ export interface ProviderDetail {
otherProvider: ToolType;
generation: number;
}>;
+
+ // Cross-provider comparison data
+ comparison: {
+ totalQueriesAllProviders: number;
+ totalCostAllProviders: number;
+ queryShare: number; // 0-100, this provider's % of total queries
+ costShare: number; // 0-100, this provider's % of total cost
+ avgResponseRanking: Array<{ provider: string; avgMs: number }>; // sorted fastest first
+ reliabilityRanking: Array<{ provider: string; rate: number }>; // sorted highest first
+ };
}
export interface UseProviderDetailResult {
@@ -169,10 +179,11 @@ export function useProviderDetail(
const refresh = useCallback(async () => {
try {
- // Fetch all data in parallel — includes failover config for threshold
- const [agents, errorStats, savedConfig, queryEvents, aggregation] = await Promise.all([
+ // Fetch all data in parallel — includes failover config for threshold + all error stats for comparison
+ const [agents, errorStats, allErrorStats, savedConfig, queryEvents, aggregation] = await Promise.all([
window.maestro.agents.detect() as Promise>,
window.maestro.providers.getErrorStats(toolType) as Promise,
+ window.maestro.providers.getAllErrorStats() as Promise>,
window.maestro.settings.get('providerSwitchConfig') as Promise | null>,
window.maestro.stats.getStats(timeRangeRef.current, { agentType: toolType }),
window.maestro.stats.getAggregation(timeRangeRef.current) as Promise,
@@ -319,6 +330,64 @@ export function useProviderDetail(
? computeP95(durations)
: usage.avgDurationMs;
+ // Cross-provider comparison from byAgent aggregation
+ const byAgent = aggregation.byAgent ?? {};
+ let totalQueriesAll = 0;
+ let totalCostAll = 0;
+ const avgResponseRanking: Array<{ provider: string; avgMs: number }> = [];
+
+ for (const [agentId, data] of Object.entries(byAgent)) {
+ totalQueriesAll += data.count;
+ // Cost isn't in byAgent — we accumulate a rough estimate from duration ratio
+ const avgMs = data.count > 0 ? Math.round(data.duration / data.count) : 0;
+ avgResponseRanking.push({ provider: getAgentDisplayName(agentId as ToolType), avgMs });
+ }
+
+ // For cost, we need per-provider data — use usage stats for this provider
+ // and aggregate from byAgent counts as proxy (actual cost only available for this provider)
+ // A simpler approach: total cost from aggregation isn't per-provider, so use the
+ // current provider's cost and approximate others from query ratios
+ // Actually, we can compute totalCostAll from the allErrorStats + byAgent combo
+ // Best approach: use totalQueries ratio for cost share approximation
+ // But we already have the actual cost for THIS provider from queryEvents
+ totalCostAll = usage.totalCostUsd; // Start with this provider's known cost
+ for (const [agentId, data] of Object.entries(byAgent)) {
+ if (agentId !== toolType && data.count > 0 && usage.queryCount > 0) {
+ // Estimate other providers' cost proportionally to query count
+ const costPerQuery = usage.totalCostUsd / usage.queryCount;
+ totalCostAll += data.count * costPerQuery;
+ }
+ }
+
+ avgResponseRanking.sort((a, b) => a.avgMs - b.avgMs); // fastest first
+
+ // Reliability ranking from allErrorStats
+ const reliabilityRanking: Array<{ provider: string; rate: number }> = [];
+ for (const [agentId, data] of Object.entries(byAgent)) {
+ const providerErrors = allErrorStats[agentId]?.totalErrorsInWindow ?? 0;
+ const rate = data.count > 0
+ ? ((data.count - providerErrors) / data.count) * 100
+ : 100;
+ reliabilityRanking.push({ provider: getAgentDisplayName(agentId as ToolType), rate });
+ }
+ reliabilityRanking.sort((a, b) => b.rate - a.rate); // highest first
+
+ const queryShare = totalQueriesAll > 0
+ ? (usage.queryCount / totalQueriesAll) * 100
+ : 0;
+ const costShare = totalCostAll > 0
+ ? (usage.totalCostUsd / totalCostAll) * 100
+ : 0;
+
+ const comparison: ProviderDetail['comparison'] = {
+ totalQueriesAllProviders: totalQueriesAll,
+ totalCostAllProviders: totalCostAll,
+ queryShare,
+ costShare,
+ avgResponseRanking,
+ reliabilityRanking,
+ };
+
const result: ProviderDetail = {
toolType,
displayName: getAgentDisplayName(toolType),
@@ -349,6 +418,7 @@ export function useProviderDetail(
hourlyPattern,
activeSessions,
migrations,
+ comparison,
};
setDetail(result);
From d25db5bf287a86b7524f0da0636c74ae4dc30647 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 19:45:02 -0500
Subject: [PATCH 50/59] MAESTRO: fix provider detail charts, add per-agent
hourly stats, and add unarchive context menu
- Remove duplicate reliability gauge from ProviderDetailCharts (was shown both as
MetricCard and as Chart 6); move error breakdown inline to ProviderDetailView
- Fix empty Activity by Hour chart by adding byAgentByHour SQL aggregation,
matching the working byAgentByDay pattern instead of client-side queryEvents
- Add "Unarchive" option to agent right-click menu for archived sessions (gated
behind Virtuosos), with conflict detection modal when another active agent of
the same provider exists (offers archive or delete of conflicting agent)
Co-Authored-By: Claude Opus 4.6
---
src/main/stats/aggregations.ts | 38 ++++
src/renderer/App.tsx | 103 +++++++++++
.../components/ProviderDetailCharts.tsx | 166 ------------------
.../components/ProviderDetailView.tsx | 55 +++++-
src/renderer/components/SessionList.tsx | 21 +++
.../components/UnarchiveConflictModal.tsx | 122 +++++++++++++
.../hooks/props/useSessionListProps.ts | 3 +
src/renderer/hooks/useProviderDetail.ts | 16 +-
src/shared/stats-types.ts | 2 +
9 files changed, 350 insertions(+), 176 deletions(-)
create mode 100644 src/renderer/components/UnarchiveConflictModal.tsx
diff --git a/src/main/stats/aggregations.ts b/src/main/stats/aggregations.ts
index 68c2ddfdf..cbdef1e9b 100644
--- a/src/main/stats/aggregations.ts
+++ b/src/main/stats/aggregations.ts
@@ -187,6 +187,42 @@ function queryByHour(
return rows;
}
+function queryByAgentByHour(
+ db: Database.Database,
+ startTime: number
+): Record> {
+ const perfStart = perfMetrics.start();
+ const rows = db
+ .prepare(
+ `
+ SELECT agent_type,
+ CAST(strftime('%H', start_time / 1000, 'unixepoch', 'localtime') AS INTEGER) as hour,
+ COUNT(*) as count,
+ SUM(duration) as duration
+ FROM query_events
+ WHERE start_time >= ?
+ GROUP BY agent_type, hour
+ ORDER BY agent_type, hour ASC
+ `
+ )
+ .all(startTime) as Array<{
+ agent_type: string;
+ hour: number;
+ count: number;
+ duration: number;
+ }>;
+
+ const result: Record> = {};
+ for (const row of rows) {
+ if (!result[row.agent_type]) {
+ result[row.agent_type] = [];
+ }
+ result[row.agent_type].push({ hour: row.hour, count: row.count, duration: row.duration });
+ }
+ perfMetrics.end(perfStart, 'getAggregatedStats:byAgentByHour');
+ return result;
+}
+
function querySessionStats(
db: Database.Database,
startTime: number
@@ -320,6 +356,7 @@ export function getAggregatedStats(db: Database.Database, range: StatsTimeRange)
const byDay = queryByDay(db, startTime);
const byAgentByDay = queryByAgentByDay(db, startTime);
const byHour = queryByHour(db, startTime);
+ const byAgentByHour = queryByAgentByHour(db, startTime);
const sessionStats = querySessionStats(db, startTime);
const bySessionByDay = queryBySessionByDay(db, startTime);
@@ -348,6 +385,7 @@ export function getAggregatedStats(db: Database.Database, range: StatsTimeRange)
byHour,
...sessionStats,
byAgentByDay,
+ byAgentByHour,
bySessionByDay,
};
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index d0a272c22..44f086fa4 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -44,6 +44,7 @@ import { TourOverlay } from './components/Wizard/tour';
import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges';
import { EmptyStateView } from './components/EmptyStateView';
import { DeleteAgentConfirmModal } from './components/DeleteAgentConfirmModal';
+import { UnarchiveConflictModal } from './components/UnarchiveConflictModal';
import { AccountSwitchModal } from './components/AccountSwitchModal';
import { VirtuososModal } from './components/VirtuososModal';
import { SwitchProviderModal } from './components/SwitchProviderModal';
@@ -866,6 +867,12 @@ function MaestroConsoleInner() {
// Provider Switch state
const [switchProviderSession, setSwitchProviderSession] = useState(null);
+ // Unarchive conflict state
+ const [unarchiveConflictState, setUnarchiveConflictState] = useState<{
+ archivedSession: Session;
+ conflictingSession: Session;
+ } | null>(null);
+
// Note: Git Diff State, Tour Overlay State, and Git Log Viewer State are from modalStore
// Note: Renaming state (editingGroupId/editingSessionId) and drag state (draggingSessionId)
@@ -5015,6 +5022,89 @@ You are taking over this conversation. Based on the context above, provide a bri
setSwitchProviderSession(null);
}, [switchProviderSession, switchProvider, setActiveSessionId]);
+ // Unarchive handlers
+ const handleUnarchive = useCallback((sessionId: string) => {
+ const session = sessionsRef.current.find(s => s.id === sessionId);
+ if (!session || !session.archivedByMigration) return;
+
+ // Check for conflict: another non-archived session with the same toolType
+ const conflicting = sessionsRef.current.find(
+ s => s.id !== sessionId
+ && s.toolType === session.toolType
+ && !s.archivedByMigration
+ );
+
+ if (conflicting) {
+ setUnarchiveConflictState({
+ archivedSession: session,
+ conflictingSession: conflicting,
+ });
+ } else {
+ // No conflict — directly unarchive
+ setSessions(prev => prev.map(s =>
+ s.id === sessionId
+ ? { ...s, archivedByMigration: false, migratedToSessionId: undefined }
+ : s
+ ));
+ notifyToast({
+ type: 'success',
+ title: 'Agent Unarchived',
+ message: `${session.name || 'Agent'} has been restored`,
+ duration: 3_000,
+ });
+ }
+ }, []);
+
+ const handleUnarchiveWithArchiveConflict = useCallback(() => {
+ if (!unarchiveConflictState) return;
+ const { archivedSession, conflictingSession } = unarchiveConflictState;
+
+ setSessions(prev => prev.map(s => {
+ if (s.id === archivedSession.id) {
+ return { ...s, archivedByMigration: false, migratedToSessionId: undefined };
+ }
+ if (s.id === conflictingSession.id) {
+ return { ...s, archivedByMigration: true };
+ }
+ return s;
+ }));
+
+ notifyToast({
+ type: 'success',
+ title: 'Agent Unarchived',
+ message: `${archivedSession.name || 'Agent'} restored, ${conflictingSession.name || 'agent'} archived`,
+ duration: 5_000,
+ });
+
+ setUnarchiveConflictState(null);
+ }, [unarchiveConflictState]);
+
+ const handleUnarchiveWithDeleteConflict = useCallback(() => {
+ if (!unarchiveConflictState) return;
+ const { archivedSession, conflictingSession } = unarchiveConflictState;
+
+ setSessions(prev => prev
+ .filter(s => s.id !== conflictingSession.id)
+ .map(s =>
+ s.id === archivedSession.id
+ ? { ...s, archivedByMigration: false, migratedToSessionId: undefined }
+ : s
+ )
+ );
+
+ // Kill process for deleted session if running
+ window.maestro.process.kill(conflictingSession.id).catch(() => {});
+
+ notifyToast({
+ type: 'success',
+ title: 'Agent Unarchived',
+ message: `${archivedSession.name || 'Agent'} restored, ${conflictingSession.name || 'agent'} removed`,
+ duration: 5_000,
+ });
+
+ setUnarchiveConflictState(null);
+ }, [unarchiveConflictState]);
+
const handleRenameTab = useCallback(
(newName: string) => {
if (!activeSession || !renameTabId) return;
@@ -8738,6 +8828,7 @@ You are taking over this conversation. Based on the context above, provide a bri
handleDeleteWorktreeSession,
handleToggleWorktreeExpanded,
handleSwitchProvider: encoreFeatures.virtuosos ? handleSwitchProvider : undefined,
+ handleUnarchive: encoreFeatures.virtuosos ? handleUnarchive : undefined,
openWizardModal,
handleStartTour,
@@ -9682,6 +9773,18 @@ You are taking over this conversation. Based on the context above, provide a bri
/>
)}
+ {/* Unarchive Conflict Modal */}
+ {unarchiveConflictState && (
+ setUnarchiveConflictState(null)}
+ />
+ )}
+
{/* Account Switch Confirmation Modal */}
{encoreFeatures.virtuosos && switchPromptData && (
= {
- auth_expired: 'Auth Expired',
- token_exhaustion: 'Token Exhaustion',
- rate_limited: 'Rate Limited',
- network_error: 'Network Error',
- agent_crashed: 'Agent Crashed',
- permission_denied: 'Permission Denied',
- session_not_found: 'Session Not Found',
- unknown: 'Unknown',
-};
-
// ============================================================================
// Chart wrapper
// ============================================================================
@@ -969,150 +956,6 @@ function SourceLocationSplitChart({
);
}
-// ============================================================================
-// Chart 6: Reliability Score (gauge metric card)
-// ============================================================================
-
-function ReliabilityGaugeChart({
- theme,
- reliability,
- queryCount,
-}: {
- theme: Theme;
- reliability: ProviderDetail['reliability'];
- queryCount: number;
-}) {
- const gaugeColor = useMemo(() => {
- if (queryCount === 0) return theme.colors.textDim;
- if (reliability.successRate >= 95) return theme.colors.success;
- if (reliability.successRate >= 85) return theme.colors.warning;
- return theme.colors.error;
- }, [reliability.successRate, queryCount, theme]);
-
- const displayRate = queryCount > 0 ? `${reliability.successRate.toFixed(1)}%` : 'N/A';
-
- // Error type breakdown
- const errorEntries = useMemo(() => {
- const entries: Array<{ type: AgentErrorType; count: number }> = [];
- for (const [type, count] of Object.entries(reliability.errorsByType)) {
- if (count && count > 0) {
- entries.push({ type: type as AgentErrorType, count });
- }
- }
- return entries.sort((a, b) => b.count - a.count);
- }, [reliability.errorsByType]);
-
- // SVG gauge arc
- const gaugeSize = 80;
- const gaugeRadius = 32;
- const gaugeThickness = 6;
- const cx = gaugeSize / 2;
- const cy = gaugeSize / 2 + 4;
- const startAngle = -Math.PI * 0.8;
- const endAngle = Math.PI * 0.8;
- const totalArc = endAngle - startAngle;
- const filledAngle = startAngle + (queryCount > 0 ? (reliability.successRate / 100) : 0) * totalArc;
-
- const bgArcStart = {
- x: cx + gaugeRadius * Math.cos(startAngle),
- y: cy + gaugeRadius * Math.sin(startAngle),
- };
- const bgArcEnd = {
- x: cx + gaugeRadius * Math.cos(endAngle),
- y: cy + gaugeRadius * Math.sin(endAngle),
- };
- const filledArcEnd = {
- x: cx + gaugeRadius * Math.cos(filledAngle),
- y: cy + gaugeRadius * Math.sin(filledAngle),
- };
- const largeArc = totalArc > Math.PI ? 1 : 0;
- const filledLargeArc = (filledAngle - startAngle) > Math.PI ? 1 : 0;
-
- return (
-
- {/* Gauge */}
-
-
- {/* Background arc */}
-
- {/* Filled arc */}
- {queryCount > 0 && reliability.successRate > 0 && (
-
- )}
- {/* Center text */}
-
- {displayRate}
-
-
- reliable
-
-
-
-
- {/* Error breakdown */}
- {errorEntries.length > 0 && (
-
- {errorEntries.map((e) => (
-
- {ERROR_TYPE_LABELS[e.type] ?? e.type}: {e.count}
-
- ))}
-
- )}
-
- {/* Zero errors note */}
- {reliability.totalErrors === 0 && queryCount > 0 && (
-
- No errors recorded
-
- )}
-
- );
-}
-
// ============================================================================
// Main component
// ============================================================================
@@ -1167,15 +1010,6 @@ export function ProviderDetailCharts({ theme, detail }: ProviderDetailChartsProp
queriesByLocation={detail.queriesByLocation}
/>
-
- {/* Chart 6: Reliability Score */}
-
-
-
);
}
diff --git a/src/renderer/components/ProviderDetailView.tsx b/src/renderer/components/ProviderDetailView.tsx
index cf6c32ace..f9b7410ae 100644
--- a/src/renderer/components/ProviderDetailView.tsx
+++ b/src/renderer/components/ProviderDetailView.tsx
@@ -8,7 +8,7 @@
import React, { useEffect } from 'react';
import { ArrowLeft, ArrowRightLeft } from 'lucide-react';
import type { Theme, Session } from '../types';
-import type { ToolType } from '../../shared/types';
+import type { ToolType, AgentErrorType } from '../../shared/types';
import type { StatsTimeRange } from '../../shared/stats-types';
import { getAgentIcon } from '../constants/agentIcons';
import { formatTokenCount } from '../hooks/useAccountUsage';
@@ -338,6 +338,11 @@ export function ProviderDetailView({
/>
+ {/* Error type breakdown (compact inline, only when errors exist) */}
+ {detail.reliability.totalErrors > 0 && (
+
+ )}
+
{/* Charts and visualizations */}
@@ -366,6 +371,54 @@ export function ProviderDetailView({
// Sub-components
// ============================================================================
+const ERROR_TYPE_LABELS: Record
= {
+ auth_expired: 'Auth Expired',
+ token_exhaustion: 'Token Exhaustion',
+ rate_limited: 'Rate Limited',
+ network_error: 'Network Error',
+ agent_crashed: 'Agent Crashed',
+ permission_denied: 'Permission Denied',
+ session_not_found: 'Session Not Found',
+ unknown: 'Unknown',
+};
+
+function ErrorBreakdownBar({
+ theme,
+ errorsByType,
+}: {
+ theme: Theme;
+ errorsByType: Partial>;
+}) {
+ const entries = Object.entries(errorsByType)
+ .filter(([, count]) => count && count > 0)
+ .sort(([, a], [, b]) => (b ?? 0) - (a ?? 0)) as Array<[AgentErrorType, number]>;
+
+ if (entries.length === 0) return null;
+
+ return (
+
+ Errors:
+ {entries.map(([type, count]) => (
+
+ {ERROR_TYPE_LABELS[type] ?? type}: {count}
+
+ ))}
+
+ );
+}
+
function MetricCard({
theme,
label,
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index f1536fbde..538693e1e 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -39,6 +39,7 @@ import {
User,
Users,
ArrowRightLeft,
+ ArchiveRestore,
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import type {
@@ -86,6 +87,7 @@ interface SessionContextMenuProps {
onDeleteWorktree?: () => void; // For worktree child sessions to delete
onCreateGroup?: () => void; // Creates a new group from the Move to Group submenu
onSwitchProvider?: () => void; // Opens SwitchProviderModal (Virtuosos)
+ onUnarchive?: () => void; // Unarchive a migration-archived session (Virtuosos)
}
function SessionContextMenu({
@@ -108,6 +110,7 @@ function SessionContextMenu({
onDeleteWorktree,
onCreateGroup,
onSwitchProvider,
+ onUnarchive,
}: SessionContextMenuProps) {
const menuRef = useRef(null);
const moveToGroupRef = useRef(null);
@@ -230,6 +233,21 @@ function SessionContextMenu({
)}
+ {/* Unarchive (only for migration-archived sessions) */}
+ {onUnarchive && session.archivedByMigration && (
+ {
+ onUnarchive();
+ onDismiss();
+ }}
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-white/5 transition-colors flex items-center gap-2"
+ style={{ color: theme.colors.accent }}
+ >
+
+ Unarchive
+
+ )}
+
{/* Account info - non-clickable info item */}
{session.accountId && (
void;
+ onUnarchive?: (sessionId: string) => void;
// Rename modal handlers (for context menu rename)
setRenameInstanceModalOpen: (open: boolean) => void;
@@ -1287,6 +1306,7 @@ function SessionListInner(props: SessionListProps) {
onDeleteSession,
onDeleteWorktreeGroup,
onSwitchProvider,
+ onUnarchive,
setRenameInstanceModalOpen,
setRenameInstanceValue,
setRenameInstanceSessionId,
@@ -3130,6 +3150,7 @@ function SessionListInner(props: SessionListProps) {
: createNewGroup
}
onSwitchProvider={onSwitchProvider ? () => onSwitchProvider(contextMenuSession.id) : undefined}
+ onUnarchive={onUnarchive ? () => onUnarchive(contextMenuSession.id) : undefined}
/>
)}
diff --git a/src/renderer/components/UnarchiveConflictModal.tsx b/src/renderer/components/UnarchiveConflictModal.tsx
new file mode 100644
index 000000000..884a723a9
--- /dev/null
+++ b/src/renderer/components/UnarchiveConflictModal.tsx
@@ -0,0 +1,122 @@
+import React, { useRef, useCallback } from 'react';
+import { AlertTriangle, ArchiveRestore } from 'lucide-react';
+import type { Theme, Session } from '../types';
+import { MODAL_PRIORITIES } from '../constants/modalPriorities';
+import { Modal } from './ui/Modal';
+import { getAgentDisplayName } from '../services/contextGroomer';
+
+interface UnarchiveConflictModalProps {
+ theme: Theme;
+ /** The archived session the user wants to unarchive */
+ archivedSession: Session;
+ /** The existing non-archived session that conflicts (same toolType) */
+ conflictingSession: Session;
+ /** Called when user chooses to archive the conflicting agent, then unarchive the target */
+ onArchiveConflicting: () => void;
+ /** Called when user chooses to delete the conflicting agent, then unarchive the target */
+ onDeleteConflicting: () => void;
+ onClose: () => void;
+}
+
+export function UnarchiveConflictModal({
+ theme,
+ archivedSession,
+ conflictingSession,
+ onArchiveConflicting,
+ onDeleteConflicting,
+ onClose,
+}: UnarchiveConflictModalProps) {
+ const archiveButtonRef = useRef(null);
+
+ const handleArchiveConflicting = useCallback(() => {
+ onArchiveConflicting();
+ onClose();
+ }, [onArchiveConflicting, onClose]);
+
+ const handleDeleteConflicting = useCallback(() => {
+ onDeleteConflicting();
+ onClose();
+ }, [onDeleteConflicting, onClose]);
+
+ const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ action();
+ }
+ };
+
+ const providerName = getAgentDisplayName(archivedSession.toolType);
+ const conflictName = conflictingSession.name || 'Unnamed Agent';
+
+ return (
+ }
+ width={500}
+ zIndex={10000}
+ initialFocusRef={archiveButtonRef}
+ footer={
+
+ handleKeyDown(e, onClose)}
+ className="px-3 py-1.5 rounded border hover:bg-white/5 transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap mr-auto"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ }}
+ >
+ Cancel
+
+ handleKeyDown(e, handleArchiveConflicting)}
+ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap"
+ style={{
+ backgroundColor: theme.colors.accent,
+ color: '#ffffff',
+ }}
+ >
+ Archive “{conflictName}”
+
+ handleKeyDown(e, handleDeleteConflicting)}
+ className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap"
+ style={{
+ backgroundColor: theme.colors.error,
+ color: '#ffffff',
+ }}
+ >
+ Delete “{conflictName}”
+
+
+ }
+ >
+
+
+
+
+ Another active {providerName} agent already exists: “{conflictName}”.
+
+
+ To unarchive this agent, you must first archive or delete the
+ conflicting agent.
+
+
+
+
+ );
+}
diff --git a/src/renderer/hooks/props/useSessionListProps.ts b/src/renderer/hooks/props/useSessionListProps.ts
index cbdc0ee31..29df7f1ef 100644
--- a/src/renderer/hooks/props/useSessionListProps.ts
+++ b/src/renderer/hooks/props/useSessionListProps.ts
@@ -127,6 +127,7 @@ export interface UseSessionListPropsDeps {
handleDeleteWorktreeSession: (session: Session) => void;
handleToggleWorktreeExpanded: (sessionId: string) => void;
handleSwitchProvider?: (sessionId: string) => void;
+ handleUnarchive?: (sessionId: string) => void;
openWizardModal: () => void;
handleStartTour: () => void;
@@ -245,6 +246,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
// Provider switching (Virtuosos)
onSwitchProvider: deps.handleSwitchProvider,
+ onUnarchive: deps.handleUnarchive,
// Auto mode
activeBatchSessionIds: deps.activeBatchSessionIds,
@@ -368,6 +370,7 @@ export function useSessionListProps(deps: UseSessionListPropsDeps) {
deps.handleDeleteWorktreeSession,
deps.handleToggleWorktreeExpanded,
deps.handleSwitchProvider,
+ deps.handleUnarchive,
deps.openWizardModal,
deps.handleStartTour,
deps.handleOpenGroupChat,
diff --git a/src/renderer/hooks/useProviderDetail.ts b/src/renderer/hooks/useProviderDetail.ts
index d60cb12ee..9faf7ad59 100644
--- a/src/renderer/hooks/useProviderDetail.ts
+++ b/src/renderer/hooks/useProviderDetail.ts
@@ -268,21 +268,19 @@ export function useProviderDetail(
avgDurationMs: d.count > 0 ? Math.round(d.duration / d.count) : 0,
}));
- // Hourly pattern — compute from raw events (byHour lacks per-agent breakdown)
- const hourlyMap = new Map();
+ // Hourly pattern — use per-agent aggregation from SQL (consistent with daily trend)
+ const hourlyData = aggregation.byAgentByHour?.[toolType] ?? [];
+ const hourlyMap = new Map();
for (let h = 0; h < 24; h++) {
- hourlyMap.set(h, { count: 0, totalDuration: 0 });
+ hourlyMap.set(h, { count: 0, duration: 0 });
}
- for (const e of queryEvents) {
- const hour = new Date(e.startTime).getHours();
- const entry = hourlyMap.get(hour)!;
- entry.count += 1;
- entry.totalDuration += e.duration ?? 0;
+ for (const d of hourlyData) {
+ hourlyMap.set(d.hour, { count: d.count, duration: d.duration });
}
const hourlyPattern = Array.from(hourlyMap.entries()).map(([hour, data]) => ({
hour,
queryCount: data.count,
- avgDurationMs: data.count > 0 ? Math.round(data.totalDuration / data.count) : 0,
+ avgDurationMs: data.count > 0 ? Math.round(data.duration / data.count) : 0,
}));
// Active sessions
diff --git a/src/shared/stats-types.ts b/src/shared/stats-types.ts
index 7a104167d..d1f266a4f 100644
--- a/src/shared/stats-types.ts
+++ b/src/shared/stats-types.ts
@@ -107,6 +107,8 @@ export interface StatsAggregation {
avgSessionDuration: number;
/** Queries and duration by provider per day (for provider comparison) */
byAgentByDay: Record>;
+ /** Queries and duration by provider per hour of day (for provider detail hourly chart) */
+ byAgentByHour: Record>;
/** Queries and duration by Maestro session per day (for agent usage chart) */
bySessionByDay: Record>;
}
From acc91b5d2462d71b3486e7eb7b67524e59138843 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 19:53:37 -0500
Subject: [PATCH 51/59] MAESTRO: fix unarchive conflict detection and modal
accessibility
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix false-positive conflicts by requiring both name AND toolType match
when checking for existing agents during unarchive. Previously only
toolType was checked, causing unrelated agents (e.g., AGENMONITORWIDGET)
to block unarchiving a different agent on the same provider.
Fix archive button contrast on UnarchiveConflictModal — use theme.colors.bg
instead of hardcoded #ffffff for text on accent background.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/App.tsx | 3 ++-
src/renderer/components/UnarchiveConflictModal.tsx | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 44f086fa4..0ccad29f3 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -5027,10 +5027,11 @@ You are taking over this conversation. Based on the context above, provide a bri
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session || !session.archivedByMigration) return;
- // Check for conflict: another non-archived session with the same toolType
+ // Check for conflict: another non-archived session with the same name AND toolType
const conflicting = sessionsRef.current.find(
s => s.id !== sessionId
&& s.toolType === session.toolType
+ && s.name === session.name
&& !s.archivedByMigration
);
diff --git a/src/renderer/components/UnarchiveConflictModal.tsx b/src/renderer/components/UnarchiveConflictModal.tsx
index 884a723a9..e1247c0db 100644
--- a/src/renderer/components/UnarchiveConflictModal.tsx
+++ b/src/renderer/components/UnarchiveConflictModal.tsx
@@ -80,7 +80,7 @@ export function UnarchiveConflictModal({
className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap"
style={{
backgroundColor: theme.colors.accent,
- color: '#ffffff',
+ color: theme.colors.bg,
}}
>
Archive “{conflictName}”
From 29e087f2f9b4671ea98ffb75d2929792ad1f76fa Mon Sep 17 00:00:00 2001
From: openasocket
Date: Thu, 19 Feb 2026 20:26:48 -0500
Subject: [PATCH 52/59] MAESTRO: multi-provider account support in Virtuosos
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extend account system from Claude-only to all supported providers:
- Widen MultiplexableAgent type to include codex, opencode, factory-droid,
and gemini-cli alongside claude-code
- Expand discoverExistingAccounts() to scan for ~/.codex, ~/.opencode,
~/.gemini config directories with provider-specific auth detection
- Group accounts by provider in AccountsPanel with labeled section headers
and count badges (e.g., "Claude Code — 2 accounts")
- Add provider selector dropdown to the Create New Virtuoso form
- Pass agentType through IPC add/discover pipeline (registry, handler,
preload, global.d.ts)
- Fix unarchive conflict detection to match on name + toolType
- Fix archive button accessibility (theme.colors.bg for contrast)
Co-Authored-By: Claude Opus 4.6
---
src/main/accounts/account-registry.ts | 3 +-
src/main/accounts/account-setup.ts | 142 ++++++++++++++---
src/main/ipc/handlers/accounts.ts | 4 +-
src/main/preload/accounts.ts | 6 +-
.../__tests__/provider-error-tracker.test.ts | 1 +
src/renderer/App.tsx | 8 +-
src/renderer/components/AccountSelector.tsx | 2 +-
.../components/AccountSwitchModal.tsx | 1 -
.../components/AccountUsageHistory.tsx | 2 +-
src/renderer/components/AccountsPanel.tsx | 145 ++++++++++++++----
.../components/ProviderDetailView.tsx | 4 +-
.../components/ProviderHealthCard.tsx | 5 +-
src/renderer/components/ProviderPanel.tsx | 2 +-
.../components/UnarchiveConflictModal.tsx | 2 +-
.../UsageDashboard/AccountUsageDashboard.tsx | 3 +-
src/renderer/components/VirtuosoUsageView.tsx | 3 +-
src/renderer/components/VirtuososModal.tsx | 2 +-
src/renderer/global.d.ts | 4 +-
.../hooks/agent/useAgentErrorRecovery.tsx | 2 +-
src/renderer/hooks/useAccountUsage.ts | 9 --
src/renderer/hooks/useProviderDetail.ts | 2 +-
src/shared/account-types.ts | 2 +-
22 files changed, 265 insertions(+), 89 deletions(-)
diff --git a/src/main/accounts/account-registry.ts b/src/main/accounts/account-registry.ts
index 4c87cf66c..5b8a8f0d5 100644
--- a/src/main/accounts/account-registry.ts
+++ b/src/main/accounts/account-registry.ts
@@ -6,6 +6,7 @@ import type {
AccountSwitchConfig,
AccountId,
AccountStatus,
+ MultiplexableAgent,
} from '../../shared/account-types';
import { DEFAULT_TOKEN_WINDOW_MS, ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
import { generateUUID } from '../../shared/uuid';
@@ -57,7 +58,7 @@ export class AccountRegistry {
name: string;
email: string;
configDir: string;
- agentType?: 'claude-code';
+ agentType?: MultiplexableAgent;
authMethod?: 'oauth' | 'api-key';
}): AccountProfile {
// Check for duplicate email
diff --git a/src/main/accounts/account-setup.ts b/src/main/accounts/account-setup.ts
index c29f98b52..fea3441bc 100644
--- a/src/main/accounts/account-setup.ts
+++ b/src/main/accounts/account-setup.ts
@@ -56,42 +56,148 @@ export async function validateBaseClaudeDir(): Promise<{
}
/**
- * Discover existing Claude account directories by scanning for ~/.claude-* directories
- * that contain a .claude.json file.
+ * Provider-specific config for discovery.
+ * Each entry describes where to find accounts and how to extract identity.
+ */
+interface ProviderDiscoveryConfig {
+ agentType: string;
+ /** Directory prefix to scan in home dir (e.g., '.claude-' matches ~/.claude-work) */
+ dirPrefix?: string;
+ /** Single config dir to detect as an account (e.g., '.codex' matches ~/.codex) */
+ singleDir?: string;
+ /** Auth files to check (relative to config dir) — first found wins */
+ authFiles: string[];
+ /** Extract identity from auth/config file content */
+ extractIdentity: (content: string) => string | null;
+}
+
+const PROVIDER_DISCOVERY: ProviderDiscoveryConfig[] = [
+ {
+ agentType: 'claude-code',
+ dirPrefix: '.claude-',
+ authFiles: ['.claude.json', '.credentials.json'],
+ extractIdentity: extractEmailFromClaudeJson,
+ },
+ {
+ agentType: 'codex',
+ singleDir: '.codex',
+ authFiles: ['auth.json', 'config.toml'],
+ extractIdentity: extractCodexIdentity,
+ },
+ {
+ agentType: 'opencode',
+ singleDir: '.opencode',
+ authFiles: ['config.json', 'auth.json'],
+ extractIdentity: () => null,
+ },
+ {
+ agentType: 'gemini-cli',
+ singleDir: '.gemini',
+ authFiles: ['oauth_creds.json', 'google_accounts.json'],
+ extractIdentity: extractGeminiIdentity,
+ },
+];
+
+/**
+ * Discover existing provider account directories by scanning the home directory
+ * for known config directory patterns across all supported providers.
*/
export async function discoverExistingAccounts(): Promise> {
const homeDir = os.homedir();
const entries = await fs.readdir(homeDir, { withFileTypes: true });
- const accounts: Array<{ configDir: string; name: string; email: string | null; hasAuth: boolean }> = [];
+ const accounts: Array<{ configDir: string; name: string; email: string | null; hasAuth: boolean; agentType: string }> = [];
+
+ for (const provider of PROVIDER_DISCOVERY) {
+ // Scan for prefix-based directories (e.g., ~/.claude-work, ~/.claude-personal)
+ if (provider.dirPrefix) {
+ for (const entry of entries) {
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
+ if (!entry.name.startsWith(provider.dirPrefix)) continue;
+
+ const configDir = path.join(homeDir, entry.name);
+ const name = entry.name.replace(provider.dirPrefix, '');
+ const authResult = await checkProviderAuth(configDir, provider);
+
+ accounts.push({
+ configDir,
+ name,
+ email: authResult.email,
+ hasAuth: authResult.hasAuth,
+ agentType: provider.agentType,
+ });
+ }
+ }
- for (const entry of entries) {
- if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
- if (!entry.name.startsWith('.claude-')) continue;
+ // Check for single config directory (e.g., ~/.codex)
+ if (provider.singleDir) {
+ const configDir = path.join(homeDir, provider.singleDir);
+ try {
+ const stat = await fs.stat(configDir);
+ if (stat.isDirectory()) {
+ const authResult = await checkProviderAuth(configDir, provider);
+ accounts.push({
+ configDir,
+ name: provider.singleDir.replace('.', ''),
+ email: authResult.email,
+ hasAuth: authResult.hasAuth,
+ agentType: provider.agentType,
+ });
+ }
+ } catch {
+ // Directory doesn't exist — skip
+ }
+ }
+ }
- const configDir = path.join(homeDir, entry.name);
- const name = entry.name.replace('.claude-', '');
+ return accounts;
+}
- // Check if it has auth tokens
- let hasAuth = false;
- let email: string | null = null;
+/** Check auth files for a provider directory and extract identity */
+async function checkProviderAuth(
+ configDir: string,
+ provider: ProviderDiscoveryConfig,
+): Promise<{ hasAuth: boolean; email: string | null }> {
+ for (const authFile of provider.authFiles) {
try {
- const authFile = path.join(configDir, '.claude.json');
- const content = await fs.readFile(authFile, 'utf-8');
- hasAuth = true;
- email = extractEmailFromClaudeJson(content);
+ const content = await fs.readFile(path.join(configDir, authFile), 'utf-8');
+ return {
+ hasAuth: true,
+ email: provider.extractIdentity(content),
+ };
} catch {
- // No auth file or unreadable
+ // Try next auth file
}
+ }
+ return { hasAuth: false, email: null };
+}
- accounts.push({ configDir, name, email, hasAuth });
+/** Extract identity from Codex auth.json or config.toml */
+function extractCodexIdentity(content: string): string | null {
+ try {
+ // auth.json has account info
+ const json = JSON.parse(content);
+ return json.email || json.user?.email || json.account?.email || null;
+ } catch {
+ // Not JSON — might be config.toml, no identity info there
+ return null;
}
+}
- return accounts;
+/** Extract identity from Gemini google_accounts.json or oauth_creds.json */
+function extractGeminiIdentity(content: string): string | null {
+ try {
+ const json = JSON.parse(content);
+ // google_accounts.json has { active: "email@example.com" }
+ return json.active || json.email || null;
+ } catch {
+ return null;
+ }
}
/**
diff --git a/src/main/ipc/handlers/accounts.ts b/src/main/ipc/handlers/accounts.ts
index 272160da5..3abfce5e3 100644
--- a/src/main/ipc/handlers/accounts.ts
+++ b/src/main/ipc/handlers/accounts.ts
@@ -15,7 +15,7 @@ import type { AccountRegistry } from '../../accounts/account-registry';
import type { AccountSwitcher } from '../../accounts/account-switcher';
import type { AccountAuthRecovery } from '../../accounts/account-auth-recovery';
import type { AccountRecoveryPoller } from '../../accounts/account-recovery-poller';
-import type { AccountSwitchConfig, AccountSwitchEvent } from '../../../shared/account-types';
+import type { AccountSwitchConfig, AccountSwitchEvent, MultiplexableAgent } from '../../../shared/account-types';
import { getStatsDB } from '../../stats';
import { logger } from '../../utils/logger';
import {
@@ -79,7 +79,7 @@ export function registerAccountHandlers(deps: AccountHandlerDependencies): void
});
ipcMain.handle('accounts:add', async (_event, params: {
- name: string; email: string; configDir: string;
+ name: string; email: string; configDir: string; agentType?: MultiplexableAgent;
}) => {
try {
const profile = requireRegistry().add(params);
diff --git a/src/main/preload/accounts.ts b/src/main/preload/accounts.ts
index 655e5f41e..27c3a6b39 100644
--- a/src/main/preload/accounts.ts
+++ b/src/main/preload/accounts.ts
@@ -56,7 +56,7 @@ export function createAccountsApi() {
get: (id: string): Promise => ipcRenderer.invoke('accounts:get', id),
/** Add a new account */
- add: (params: { name: string; email: string; configDir: string }): Promise =>
+ add: (params: { name: string; email: string; configDir: string; agentType?: string }): Promise =>
ipcRenderer.invoke('accounts:add', params),
/** Update an existing account */
@@ -131,8 +131,8 @@ export function createAccountsApi() {
validateBaseDir: (): Promise<{ valid: boolean; baseDir: string; errors: string[] }> =>
ipcRenderer.invoke('accounts:validate-base-dir'),
- /** Discover existing ~/.claude-* account directories */
- discoverExisting: (): Promise> =>
+ /** Discover existing provider account directories */
+ discoverExisting: (): Promise> =>
ipcRenderer.invoke('accounts:discover-existing'),
/** Create a new account directory with symlinks */
diff --git a/src/main/providers/__tests__/provider-error-tracker.test.ts b/src/main/providers/__tests__/provider-error-tracker.test.ts
index f4276d2c2..6a6c4bf4b 100644
--- a/src/main/providers/__tests__/provider-error-tracker.test.ts
+++ b/src/main/providers/__tests__/provider-error-tracker.test.ts
@@ -17,6 +17,7 @@ describe('ProviderErrorTracker', () => {
errorThreshold: 3,
errorWindowMs: 5 * 60 * 1000, // 5 minutes
fallbackProviders: ['claude-code', 'opencode', 'codex'],
+ switchBehavior: 'merge-back',
};
beforeEach(() => {
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 0ccad29f3..cb1f20620 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -2781,10 +2781,10 @@ You are taking over this conversation. Based on the context above, provide a bri
const {
switchProvider,
- transferState: providerSwitchState,
- progress: providerSwitchProgress,
- error: providerSwitchError,
- cancelSwitch: cancelProviderSwitch,
+ transferState: _providerSwitchState,
+ progress: _providerSwitchProgress,
+ error: _providerSwitchError,
+ cancelSwitch: _cancelProviderSwitch,
reset: resetProviderSwitch,
} = useProviderSwitch();
diff --git a/src/renderer/components/AccountSelector.tsx b/src/renderer/components/AccountSelector.tsx
index 6b6c88652..c26936267 100644
--- a/src/renderer/components/AccountSelector.tsx
+++ b/src/renderer/components/AccountSelector.tsx
@@ -7,7 +7,7 @@
* Lists all active accounts with status dots, usage bars, and a "Manage Accounts" link.
*/
-import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback } from 'react';
import { User, ChevronDown, Settings } from 'lucide-react';
import type { Theme } from '../types';
import type { AccountProfile } from '../../shared/account-types';
diff --git a/src/renderer/components/AccountSwitchModal.tsx b/src/renderer/components/AccountSwitchModal.tsx
index e1e343f0c..2acb19228 100644
--- a/src/renderer/components/AccountSwitchModal.tsx
+++ b/src/renderer/components/AccountSwitchModal.tsx
@@ -6,7 +6,6 @@
* target, and action buttons to confirm, dismiss, or view the dashboard.
*/
-import React from 'react';
import { AlertTriangle, ArrowRightLeft, BarChart3 } from 'lucide-react';
import type { Theme } from '../types';
import { Modal } from './ui/Modal';
diff --git a/src/renderer/components/AccountUsageHistory.tsx b/src/renderer/components/AccountUsageHistory.tsx
index 4f594e820..766537c2d 100644
--- a/src/renderer/components/AccountUsageHistory.tsx
+++ b/src/renderer/components/AccountUsageHistory.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import type { Theme } from '../types';
import { formatTokenCount } from '../hooks/useAccountUsage';
diff --git a/src/renderer/components/AccountsPanel.tsx b/src/renderer/components/AccountsPanel.tsx
index 61ed803ac..5d086c5f2 100644
--- a/src/renderer/components/AccountsPanel.tsx
+++ b/src/renderer/components/AccountsPanel.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import {
Plus,
Trash2,
@@ -14,11 +14,22 @@ import {
History,
} from 'lucide-react';
import type { Theme } from '../types';
-import type { AccountProfile, AccountSwitchConfig } from '../../shared/account-types';
+import type { AccountProfile, AccountSwitchConfig, MultiplexableAgent } from '../../shared/account-types';
import { ACCOUNT_SWITCH_DEFAULTS } from '../../shared/account-types';
import { useAccountUsage, formatTimeRemaining, formatTokenCount } from '../hooks/useAccountUsage';
import { AccountUsageHistory } from './AccountUsageHistory';
import { notifyToast } from '../stores/notificationStore';
+/** Provider types that can have accounts in Virtuosos */
+const ACCOUNT_PROVIDERS: MultiplexableAgent[] = ['claude-code', 'codex', 'gemini-cli', 'opencode', 'factory-droid'];
+
+/** Display names for all multiplexable agents (extends beyond ToolType) */
+const PROVIDER_DISPLAY_NAMES: Record = {
+ 'claude-code': 'Claude Code',
+ codex: 'OpenAI Codex',
+ 'gemini-cli': 'Gemini CLI',
+ opencode: 'OpenCode',
+ 'factory-droid': 'Factory Droid',
+};
const PLAN_PRESETS = [
{ label: 'Custom', tokens: 0, cost: null },
@@ -41,6 +52,7 @@ interface DiscoveredAccount {
name: string;
email: string | null;
hasAuth: boolean;
+ agentType: string;
}
interface ConflictingSession {
@@ -65,6 +77,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
const [isDiscovering, setIsDiscovering] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newAccountName, setNewAccountName] = useState('');
+ const [newAccountProvider, setNewAccountProvider] = useState('claude-code');
const [createStep, setCreateStep] = useState<'idle' | 'created' | 'login-ready'>('idle');
const [createdConfigDir, setCreatedConfigDir] = useState('');
const [loginCommand, setLoginCommand] = useState('');
@@ -150,6 +163,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
name: discovered.name,
email: discovered.email || discovered.name,
configDir: discovered.configDir,
+ agentType: discovered.agentType,
});
await refreshAccounts();
// Remove from discovered list
@@ -194,6 +208,7 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
name: email || newAccountName.trim(),
email: email || newAccountName.trim(),
configDir: createdConfigDir,
+ agentType: newAccountProvider,
});
await refreshAccounts();
// Reset create flow
@@ -408,8 +423,41 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
New" below.
) : (
-
- {accounts.map((account) => (
+
+ {(() => {
+ // Group accounts by provider type
+ const grouped = new Map
();
+ for (const account of accounts) {
+ const key = account.agentType || 'claude-code';
+ if (!grouped.has(key)) grouped.set(key, []);
+ grouped.get(key)!.push(account);
+ }
+ // Sort providers: providers with accounts first, in ACCOUNT_PROVIDERS order
+ const orderedProviders = ACCOUNT_PROVIDERS.filter(p => grouped.has(p));
+ return orderedProviders.map((providerType) => {
+ const providerAccounts = grouped.get(providerType) || [];
+ return (
+
+
+
+ {PROVIDER_DISPLAY_NAMES[providerType] || providerType}
+
+
+ {providerAccounts.length} account{providerAccounts.length !== 1 ? 's' : ''}
+
+
+
+ {providerAccounts.map((account) => (
)}
- ))}
+ ))}
+
+
+ );
+ });
+ })()}
)}
@@ -898,10 +951,20 @@ export function AccountsPanel({ theme }: AccountsPanelProps) {
>
{d.email || d.name}
+
+ {PROVIDER_DISPLAY_NAMES[d.agentType as MultiplexableAgent] || d.agentType}
+
{createStep === 'idle' && (
-
-
setNewAccountName(e.target.value)}
- placeholder="Virtuoso name (e.g., work, personal)"
- className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
- style={{
- borderColor: theme.colors.border,
- color: theme.colors.textMain,
- }}
- onKeyDown={(e) => e.key === 'Enter' && handleCreateAndLogin()}
- />
-
-
- {isCreating ? 'Creating...' : 'Create & Login'}
-
+
+
+
setNewAccountProvider(e.target.value as MultiplexableAgent)}
+ className="p-2 rounded border bg-transparent outline-none text-xs"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ backgroundColor: theme.colors.bgMain,
+ }}
+ >
+ {ACCOUNT_PROVIDERS.map(p => (
+
+ {PROVIDER_DISPLAY_NAMES[p] || p}
+
+ ))}
+
+
setNewAccountName(e.target.value)}
+ placeholder="Virtuoso name (e.g., work, personal)"
+ className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
+ style={{
+ borderColor: theme.colors.border,
+ color: theme.colors.textMain,
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateAndLogin()}
+ />
+
+
+ {isCreating ? 'Creating...' : 'Create & Login'}
+
+
)}
diff --git a/src/renderer/components/ProviderDetailView.tsx b/src/renderer/components/ProviderDetailView.tsx
index f9b7410ae..96e02d934 100644
--- a/src/renderer/components/ProviderDetailView.tsx
+++ b/src/renderer/components/ProviderDetailView.tsx
@@ -5,7 +5,7 @@
* and navigates back to the card grid on back button or Escape key.
*/
-import React, { useEffect } from 'react';
+import { useEffect } from 'react';
import { ArrowLeft, ArrowRightLeft } from 'lucide-react';
import type { Theme, Session } from '../types';
import type { ToolType, AgentErrorType } from '../../shared/types';
@@ -91,7 +91,7 @@ export function ProviderDetailView({
toolType,
sessions,
timeRange,
- setTimeRange,
+ setTimeRange: _setTimeRange,
onBack,
onSelectSession,
}: ProviderDetailViewProps) {
diff --git a/src/renderer/components/ProviderHealthCard.tsx b/src/renderer/components/ProviderHealthCard.tsx
index 9baae8379..c7064c4fb 100644
--- a/src/renderer/components/ProviderHealthCard.tsx
+++ b/src/renderer/components/ProviderHealthCard.tsx
@@ -8,7 +8,6 @@
* - Health bar at bottom (green/yellow/red gradient)
*/
-import React from 'react';
import type { Theme } from '../types';
import type { ToolType } from '../../shared/types';
import type { ProviderErrorStats } from '../../shared/account-types';
@@ -113,11 +112,11 @@ function formatWindowDuration(ms: number): string {
export function ProviderHealthCard({
theme,
toolType,
- available,
+ available: _available,
activeSessionCount,
errorStats,
usageStats,
- failoverThreshold,
+ failoverThreshold: _failoverThreshold,
healthPercent,
status,
onSelect,
diff --git a/src/renderer/components/ProviderPanel.tsx b/src/renderer/components/ProviderPanel.tsx
index 04166148b..987d5935b 100644
--- a/src/renderer/components/ProviderPanel.tsx
+++ b/src/renderer/components/ProviderPanel.tsx
@@ -107,7 +107,7 @@ export function ProviderPanel({ theme, sessions = [], onSelectSession }: Provide
const {
providers: healthProviders,
isLoading: healthLoading,
- lastUpdated,
+ lastUpdated: _lastUpdated,
timeRange,
setTimeRange,
refresh: refreshHealth,
diff --git a/src/renderer/components/UnarchiveConflictModal.tsx b/src/renderer/components/UnarchiveConflictModal.tsx
index e1247c0db..23f569503 100644
--- a/src/renderer/components/UnarchiveConflictModal.tsx
+++ b/src/renderer/components/UnarchiveConflictModal.tsx
@@ -80,7 +80,7 @@ export function UnarchiveConflictModal({
className="px-3 py-1.5 rounded transition-colors outline-none focus:ring-2 focus:ring-offset-1 text-xs whitespace-nowrap"
style={{
backgroundColor: theme.colors.accent,
- color: theme.colors.bg,
+ color: theme.colors.accentForeground,
}}
>
Archive “{conflictName}”
diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
index 2ff0e744a..ed442a8d8 100644
--- a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
+++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
@@ -6,7 +6,7 @@
* Integrated as a tab within the existing Usage Dashboard.
*/
-import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Users,
Activity,
@@ -14,7 +14,6 @@ import {
Zap,
TrendingUp,
ArrowRightLeft,
- Clock,
} from 'lucide-react';
import { useAccountUsage } from '../../hooks/useAccountUsage';
import { AccountTrendChart } from './AccountTrendChart';
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx
index f26ef92a7..55ad8d374 100644
--- a/src/renderer/components/VirtuosoUsageView.tsx
+++ b/src/renderer/components/VirtuosoUsageView.tsx
@@ -7,7 +7,7 @@
* C) Historical — per-account expandable history + throttle event timeline
*/
-import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Activity,
AlertTriangle,
@@ -16,7 +16,6 @@ import {
Clock,
TrendingUp,
Users,
- Zap,
BarChart3,
} from 'lucide-react';
import type { Theme, Session } from '../types';
diff --git a/src/renderer/components/VirtuososModal.tsx b/src/renderer/components/VirtuososModal.tsx
index aacfa9abb..6290369ac 100644
--- a/src/renderer/components/VirtuososModal.tsx
+++ b/src/renderer/components/VirtuososModal.tsx
@@ -7,7 +7,7 @@
* 3. Usage — VirtuosoUsageView (real-time metrics, predictions, history, throttle events)
*/
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { Users, Settings, BarChart3, ArrowRightLeft } from 'lucide-react';
import { AccountsPanel } from './AccountsPanel';
import { ProviderPanel } from './ProviderPanel';
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index 3f11ed2e8..45394c52c 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -2654,7 +2654,7 @@ interface MaestroAPI {
accounts: {
list: () => Promise
;
get: (id: string) => Promise;
- add: (params: { name: string; email: string; configDir: string }) => Promise;
+ add: (params: { name: string; email: string; configDir: string; agentType?: string }) => Promise;
update: (id: string, updates: Record) => Promise;
remove: (id: string) => Promise;
setDefault: (id: string) => Promise;
@@ -2672,7 +2672,7 @@ interface MaestroAPI {
getDefault: () => Promise;
selectNext: (excludeIds?: string[]) => Promise;
validateBaseDir: () => Promise<{ valid: boolean; baseDir: string; errors: string[] }>;
- discoverExisting: () => Promise>;
+ discoverExisting: () => Promise>;
createDirectory: (name: string) => Promise<{ success: boolean; configDir: string; error?: string }>;
validateSymlinks: (configDir: string) => Promise<{ valid: boolean; broken: string[]; missing: string[] }>;
repairSymlinks: (configDir: string) => Promise<{ repaired: string[]; errors: string[] }>;
diff --git a/src/renderer/hooks/agent/useAgentErrorRecovery.tsx b/src/renderer/hooks/agent/useAgentErrorRecovery.tsx
index 66df117e6..754e84d5f 100644
--- a/src/renderer/hooks/agent/useAgentErrorRecovery.tsx
+++ b/src/renderer/hooks/agent/useAgentErrorRecovery.tsx
@@ -56,7 +56,7 @@ export interface UseAgentErrorRecoveryResult {
*/
function getRecoveryActionsForError(
error: AgentError,
- agentId: ToolType,
+ _agentId: ToolType,
options: UseAgentErrorRecoveryOptions
): RecoveryAction[] {
const actions: RecoveryAction[] = [];
diff --git a/src/renderer/hooks/useAccountUsage.ts b/src/renderer/hooks/useAccountUsage.ts
index 10aaccbcf..1aa289b6c 100644
--- a/src/renderer/hooks/useAccountUsage.ts
+++ b/src/renderer/hooks/useAccountUsage.ts
@@ -70,15 +70,6 @@ const EMPTY_RATE_METRICS: RateMetrics = {
trend: 'stable',
};
-const EMPTY_PREDICTION: UsagePrediction = {
- linearTimeToLimitMs: null,
- weightedTimeToLimitMs: null,
- p90TokensPerWindow: 0,
- avgTokensPerWindow: 0,
- confidence: 'low',
- windowsRemainingP90: null,
-};
-
// ============================================================================
// P90 Prediction Calculator
// ============================================================================
diff --git a/src/renderer/hooks/useProviderDetail.ts b/src/renderer/hooks/useProviderDetail.ts
index 9faf7ad59..d7db062d2 100644
--- a/src/renderer/hooks/useProviderDetail.ts
+++ b/src/renderer/hooks/useProviderDetail.ts
@@ -10,7 +10,7 @@ import type { Session } from '../types';
import type { ToolType, AgentErrorType } from '../../shared/types';
import type { ProviderErrorStats, ProviderSwitchConfig } from '../../shared/account-types';
import { DEFAULT_PROVIDER_SWITCH_CONFIG } from '../../shared/account-types';
-import type { StatsTimeRange, StatsAggregation, QueryEvent } from '../../shared/stats-types';
+import type { StatsTimeRange, StatsAggregation } from '../../shared/stats-types';
import type { ProviderUsageStats } from './useProviderHealth';
import type { HealthStatus } from '../components/ProviderHealthCard';
import { getAgentDisplayName } from '../services/contextGroomer';
diff --git a/src/shared/account-types.ts b/src/shared/account-types.ts
index 938199f53..50d458199 100644
--- a/src/shared/account-types.ts
+++ b/src/shared/account-types.ts
@@ -13,7 +13,7 @@ export type AccountStatus = 'active' | 'throttled' | 'expired' | 'disabled';
export type AccountAuthMethod = 'oauth' | 'api-key';
/** Agent types that support account multiplexing */
-export type MultiplexableAgent = 'claude-code';
+export type MultiplexableAgent = 'claude-code' | 'codex' | 'opencode' | 'factory-droid' | 'gemini-cli';
/** A registered account profile */
export interface AccountProfile {
From 0838e34aac8faa12d2427cc214a55f08d5dce4ef Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sat, 28 Feb 2026 23:23:16 -0500
Subject: [PATCH 53/59] MAESTRO: regenerate package-lock.json after rebase onto
main
Ran npm install to ensure lock file is consistent with main's
dependencies. Removed 33 stale packages.
Co-Authored-By: Claude Opus 4.6
---
package-lock.json | 76 ++++++++++++++++++++---------------------------
1 file changed, 32 insertions(+), 44 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 62cc894d0..7f6c48fb4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -263,7 +263,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -667,7 +666,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -711,7 +709,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -2285,7 +2282,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
- "peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2307,7 +2303,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz",
"integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==",
"license": "Apache-2.0",
- "peer": true,
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
@@ -2320,7 +2315,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz",
"integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2336,7 +2330,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz",
"integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.208.0",
"import-in-the-middle": "^2.0.0",
@@ -2724,7 +2717,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz",
"integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2741,7 +2733,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz",
"integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@opentelemetry/core": "2.2.0",
"@opentelemetry/resources": "2.2.0",
@@ -2759,7 +2750,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz",
"integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==",
"license": "Apache-2.0",
- "peer": true,
"engines": {
"node": ">=14"
}
@@ -3818,7 +3808,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -4356,7 +4347,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -4368,7 +4358,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -4494,7 +4483,6 @@
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
@@ -4925,7 +4913,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5007,7 +4994,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -6011,7 +5997,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -6494,7 +6479,6 @@
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
@@ -7220,7 +7204,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10"
}
@@ -7630,7 +7613,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -8128,7 +8110,6 @@
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"builder-util": "24.13.1",
@@ -8224,7 +8205,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/dompurify": {
"version": "3.3.0",
@@ -8368,6 +8350,7 @@
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"app-builder-lib": "24.13.3",
"archiver": "^5.3.1",
@@ -8381,6 +8364,7 @@
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"archiver-utils": "^2.1.0",
"async": "^3.2.4",
@@ -8400,6 +8384,7 @@
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
@@ -8422,6 +8407,7 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@@ -8438,6 +8424,7 @@
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
@@ -8454,6 +8441,7 @@
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
@@ -8468,6 +8456,7 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -8483,6 +8472,7 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -8495,7 +8485,8 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/electron-builder-squirrel-windows/node_modules/string_decoder": {
"version": "1.1.1",
@@ -8503,6 +8494,7 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"safe-buffer": "~5.1.0"
}
@@ -8513,6 +8505,7 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -8523,6 +8516,7 @@
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2",
@@ -8538,6 +8532,7 @@
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"glob": "^7.2.3",
"graceful-fs": "^4.2.0",
@@ -9219,7 +9214,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -11123,7 +11117,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -11944,7 +11937,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -12414,14 +12406,16 @@
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
@@ -12434,7 +12428,8 @@
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
@@ -12448,7 +12443,8 @@
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -12462,7 +12458,8 @@
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/log-symbols": {
"version": "4.1.0",
@@ -12553,6 +12550,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -15050,7 +15048,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15291,6 +15288,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -15306,6 +15304,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -15650,7 +15649,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -15680,7 +15678,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -15728,7 +15725,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -15915,8 +15911,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -17673,7 +17668,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -17984,7 +17978,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18358,7 +18351,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -18864,7 +18856,6 @@
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.0.15",
"@vitest/mocker": "4.0.15",
@@ -19455,7 +19446,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -19469,7 +19459,6 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -20067,7 +20056,6 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
From e91fcc3835f37f67addb345c294a99330ce89723 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 1 Mar 2026 00:01:41 -0500
Subject: [PATCH 54/59] MAESTRO: fix ThrottleEvent field mismatch between
backend and frontend
Align frontend ThrottleEvent interfaces with backend: rename totalTokens
to tokensAtThrottle, remove phantom recoveryAction field, add missing id
and sessionId fields. Fixes broken/undefined token display and unused
Recovery column in throttle event tables.
Co-Authored-By: Claude Opus 4.6
---
.../UsageDashboard/AccountUsageDashboard.tsx | 300 ++++++++++++------
src/renderer/components/VirtuosoUsageView.tsx | 77 ++---
2 files changed, 231 insertions(+), 146 deletions(-)
diff --git a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
index ed442a8d8..fd52f2a3d 100644
--- a/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
+++ b/src/renderer/components/UsageDashboard/AccountUsageDashboard.tsx
@@ -7,14 +7,7 @@
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
-import {
- Users,
- Activity,
- AlertTriangle,
- Zap,
- TrendingUp,
- ArrowRightLeft,
-} from 'lucide-react';
+import { Users, Activity, AlertTriangle, Zap, TrendingUp, ArrowRightLeft } from 'lucide-react';
import { useAccountUsage } from '../../hooks/useAccountUsage';
import { AccountTrendChart } from './AccountTrendChart';
import type { Theme, Session } from '../../types';
@@ -77,12 +70,13 @@ function getStatusStyle(status: string, theme: Theme): { bg: string; fg: string
}
interface ThrottleEvent {
+ id: string;
timestamp: number;
accountId: string;
+ sessionId: string | null;
accountName?: string;
reason: string;
- totalTokens: number;
- recoveryAction?: string;
+ tokensAtThrottle: number;
}
export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDashboardProps) {
@@ -191,16 +185,18 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
// Estimate tokens/hour based on current window
const windowMs = accounts[0]?.tokenWindowMs || 5 * 60 * 60 * 1000;
const hoursInWindow = windowMs / 3_600_000;
- const avgTokensPerHour = hoursInWindow > 0 ? Math.round(totalTokens / hoursInWindow / accounts.length) : 0;
+ const avgTokensPerHour =
+ hoursInWindow > 0 ? Math.round(totalTokens / hoursInWindow / accounts.length) : 0;
const peakTokensPerHour = avgTokensPerHour * 1.5; // estimate
// Recommend accounts based on usage
const maxTokensPerAccountPerHour = accounts[0]?.tokenLimitPerWindow
? accounts[0].tokenLimitPerWindow / hoursInWindow
: 200_000;
- const recommended = maxTokensPerAccountPerHour > 0
- ? Math.max(1, Math.ceil(peakTokensPerHour / maxTokensPerAccountPerHour))
- : 1;
+ const recommended =
+ maxTokensPerAccountPerHour > 0
+ ? Math.max(1, Math.ceil(peakTokensPerHour / maxTokensPerAccountPerHour))
+ : 1;
return {
avgTokensPerHour,
@@ -213,7 +209,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
if (loading) {
return (
-
+
Loading virtuoso usage data...
);
@@ -221,7 +220,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
if (error) {
return (
-
+
Failed to load virtuoso data: {error}
+
No virtuosos registered
@@ -257,7 +262,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
Virtuoso Overview
-
+
{accounts.map((account) => {
const usage = usageData[account.id];
const percent = usage?.usagePercent ?? null;
@@ -291,7 +299,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{account.isDefault && (
DEFAULT
@@ -315,7 +326,8 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{formatTokens(totalTokens)}
{account.tokenLimitPerWindow > 0 && (
- {' / '}{formatTokens(account.tokenLimitPerWindow)}
+ {' / '}
+ {formatTokens(account.tokenLimitPerWindow)}
)}
@@ -336,7 +348,12 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{/* Sparkline */}
{/* Stats grid */}
@@ -378,12 +395,16 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
TTL
-
+
{acctMetrics.estimatedTimeToLimitMs !== null
? formatTimeRemaining(acctMetrics.estimatedTimeToLimitMs)
: '—'}
@@ -391,13 +412,17 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
Confidence
-
+
{acctMetrics.prediction.confidence}
@@ -422,7 +447,11 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{assignments.length === 0 ? (
No active account assignments
@@ -434,11 +463,36 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
- Session
- Account
- Agent
- Assigned
- Status
+
+ Session
+
+
+ Account
+
+
+ Agent
+
+
+ Assigned
+
+
+ Status
+
@@ -467,12 +521,14 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{session?.state || 'unknown'}
@@ -498,7 +554,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{accounts.map((account) => {
const usage = usageData[account.id];
@@ -508,7 +567,9 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
const maxTokens = Math.max(
...accounts.map((a) => {
const u = usageData[a.id];
- return u ? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0) : 0;
+ return u
+ ? (u.inputTokens || 0) + (u.outputTokens || 0) + (u.cacheReadTokens || 0)
+ : 0;
}),
1
);
@@ -525,10 +586,7 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{(account.email || account.name).split('@')[0]}
-
+
-
+
{formatTokens(totalTokens)}
@@ -557,8 +618,18 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{(() => {
const m = accountMetrics[account.id];
if (!m?.rateMetrics) return null;
- const trendSymbol = m.rateMetrics.trend === 'up' ? '\u2197' : m.rateMetrics.trend === 'down' ? '\u2198' : '\u2192';
- const trendColor = m.rateMetrics.trend === 'up' ? theme.colors.warning : m.rateMetrics.trend === 'down' ? theme.colors.success : theme.colors.textDim;
+ const trendSymbol =
+ m.rateMetrics.trend === 'up'
+ ? '\u2197'
+ : m.rateMetrics.trend === 'down'
+ ? '\u2198'
+ : '\u2192';
+ const trendColor =
+ m.rateMetrics.trend === 'up'
+ ? theme.colors.warning
+ : m.rateMetrics.trend === 'down'
+ ? theme.colors.success
+ : theme.colors.textDim;
return (
{trendSymbol} {formatTokens(Math.round(m.rateMetrics.tokensPerDay))}/day
@@ -583,7 +654,11 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{throttleEvents.length === 0 ? (
No throttle events recorded
@@ -595,48 +670,63 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
- Time
- Account
- Reason
- Tokens
- Recovery
+
+ Time
+
+
+ Account
+
+
+ Reason
+
+
+ Tokens
+
- {throttleEvents.slice().reverse().map((event, i) => {
- const account = accountMap.get(event.accountId);
- return (
-
-
- {new Date(event.timestamp).toLocaleString()}
-
-
- {event.accountName || account?.email || event.accountId.slice(0, 8)}
-
-
-
- {event.reason.replace(/_/g, ' ')}
-
-
-
- {formatTokens(event.totalTokens)}
-
-
- {event.recoveryAction || '—'}
-
-
- );
- })}
+ {throttleEvents
+ .slice()
+ .reverse()
+ .map((event, i) => {
+ const account = accountMap.get(event.accountId);
+ return (
+
+
+ {new Date(event.timestamp).toLocaleString()}
+
+
+ {event.accountName || account?.email || event.accountId.slice(0, 8)}
+
+
+
+ {event.reason.replace(/_/g, ' ')}
+
+
+
+ {formatTokens(event.tokensAtThrottle)}
+
+
+ );
+ })}
@@ -655,7 +745,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
Based on your usage in the current window:
@@ -675,7 +768,15 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
Throttle events
-
0 ? theme.colors.warning : theme.colors.textMain }}>
+
0
+ ? theme.colors.warning
+ : theme.colors.textMain,
+ }}
+ >
{capacityMetrics.throttleEvents}
@@ -684,7 +785,10 @@ export function AccountUsageDashboard({ theme, sessions = [] }: AccountUsageDash
{capacityMetrics.recommendedAccountCount}
{capacityMetrics.recommendedAccountCount > accounts.length && (
-
+
(need {capacityMetrics.recommendedAccountCount - accounts.length} more)
)}
diff --git a/src/renderer/components/VirtuosoUsageView.tsx b/src/renderer/components/VirtuosoUsageView.tsx
index 55ad8d374..b47ac845f 100644
--- a/src/renderer/components/VirtuosoUsageView.tsx
+++ b/src/renderer/components/VirtuosoUsageView.tsx
@@ -26,12 +26,13 @@ import { AccountTrendChart } from './UsageDashboard/AccountTrendChart';
import { AccountRateMetrics } from './UsageDashboard/AccountRateMetrics';
interface ThrottleEvent {
+ id: string;
timestamp: number;
accountId: string;
+ sessionId: string | null;
accountName?: string;
reason: string;
- totalTokens: number;
- recoveryAction?: string;
+ tokensAtThrottle: number;
}
interface VirtuosoUsageViewProps {
@@ -303,9 +304,7 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
Queries:{' '}
-
- {usage.queryCount}
-
+ {usage.queryCount}
Burn:{' '}
@@ -316,9 +315,10 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
{usage.rateMetrics.trend === 'up' ? '\u2197' : '\u2198'}
@@ -343,16 +343,21 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
{/* 7-day sparkline */}
{usage && (
-
-
+
)}
>
) : (
-
+
No usage data for current window
)}
@@ -388,10 +393,7 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
}}
>
-
+
{account.name || account.email}
At current rates, {exhaustingSoon.length} account
- {exhaustingSoon.length !== 1 ? 's' : ''} will reach limit
- within {formatTimeRemaining(soonestMs)}
+ {exhaustingSoon.length !== 1 ? 's' : ''} will reach limit within{' '}
+ {formatTimeRemaining(soonestMs)}
);
@@ -541,9 +543,7 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
- setExpandedAccountId(
- expandedAccountId === account.id ? null : account.id
- )
+ setExpandedAccountId(expandedAccountId === account.id ? null : account.id)
}
className="w-full flex items-center gap-2 py-2 px-2 rounded-lg text-xs text-left transition-colors"
style={{ color: theme.colors.textMain }}
@@ -559,16 +559,11 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
) : (
)}
-
- {account.name || account.email}
-
+ {account.name || account.email}
{expandedAccountId === account.id && (
)}
@@ -586,10 +581,7 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
Recent Throttle Events
{throttleEvents.length === 0 ? (
-
+
No throttle events recorded
) : (
@@ -600,23 +592,15 @@ export function VirtuosoUsageView({ theme, sessions }: VirtuosoUsageViewProps) {
className="flex items-center gap-3 text-xs py-1.5 border-b"
style={{ borderColor: theme.colors.border }}
>
-
+
{new Date(event.timestamp).toLocaleString()}
{event.accountName || event.accountId}
- {formatTokenCount(event.totalTokens)} tokens
+ {formatTokenCount(event.tokensAtThrottle)} tokens
- {event.recoveryAction && (
-
- → {event.recoveryAction}
-
- )}
))}
@@ -635,10 +619,7 @@ function getSeverityColor(usagePercent: number | null | undefined, theme: Theme)
return theme.colors.success;
}
-function getStatusColor(
- status: string,
- theme: Theme
-): { bg: string; fg: string } {
+function getStatusColor(status: string, theme: Theme): { bg: string; fg: string } {
const styles: Record
= {
active: { bg: theme.colors.success + '20', fg: theme.colors.success },
throttled: { bg: theme.colors.warning + '20', fg: theme.colors.warning },
From 40572adf5a90a206e0f7415f4230338b68e80503 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 1 Mar 2026 00:08:46 -0500
Subject: [PATCH 55/59] MAESTRO: fix SwitchProviderModal keyboard navigation by
adding tabIndex and auto-focus
The onKeyDown handler div lacked tabIndex, so keyboard events (arrow
keys, Enter, Space) never fired. Added tabIndex={0}, outline-none class,
and auto-focus ref so the modal receives focus on open.
Co-Authored-By: Claude Opus 4.6
---
.../components/SwitchProviderModal.tsx | 91 +++++++++++++------
1 file changed, 61 insertions(+), 30 deletions(-)
diff --git a/src/renderer/components/SwitchProviderModal.tsx b/src/renderer/components/SwitchProviderModal.tsx
index 6d268e5fe..0456b83ce 100644
--- a/src/renderer/components/SwitchProviderModal.tsx
+++ b/src/renderer/components/SwitchProviderModal.tsx
@@ -154,7 +154,11 @@ export function SwitchProviderModal({
(async () => {
try {
const saved = await window.maestro.settings.get('providerSwitchConfig');
- if (saved && typeof saved === 'object' && 'switchBehavior' in (saved as Record)) {
+ if (
+ saved &&
+ typeof saved === 'object' &&
+ 'switchBehavior' in (saved as Record)
+ ) {
setSwitchBehavior((saved as { switchBehavior: ProviderSwitchBehavior }).switchBehavior);
}
} catch {
@@ -187,10 +191,7 @@ export function SwitchProviderModal({
}, [sourceSession, sourceTabId]);
// Available (selectable) providers for keyboard nav
- const selectableProviders = useMemo(
- () => providers.filter((p) => p.available),
- [providers]
- );
+ const selectableProviders = useMemo(() => providers.filter((p) => p.available), [providers]);
// Find archived predecessor when target provider changes
const archivedPredecessor = useMemo(() => {
@@ -212,18 +213,24 @@ export function SwitchProviderModal({
targetProvider: selectedProvider,
groomContext,
archiveSource,
- mergeBackInto: archivedPredecessor && mergeChoice === 'merge' ? archivedPredecessor : undefined,
+ mergeBackInto:
+ archivedPredecessor && mergeChoice === 'merge' ? archivedPredecessor : undefined,
});
- }, [selectedProvider, groomContext, archiveSource, archivedPredecessor, mergeChoice, onConfirmSwitch]);
+ }, [
+ selectedProvider,
+ groomContext,
+ archiveSource,
+ archivedPredecessor,
+ mergeChoice,
+ onConfirmSwitch,
+ ]);
// Keyboard navigation handler
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
- setHighlightedIndex((prev) =>
- prev + 1 < selectableProviders.length ? prev + 1 : prev
- );
+ setHighlightedIndex((prev) => (prev + 1 < selectableProviders.length ? prev + 1 : prev));
return;
}
@@ -294,18 +301,23 @@ export function SwitchProviderModal({
color: theme.colors.accentForeground,
}}
>
- {archivedPredecessor && mergeChoice === 'merge'
- ? 'Merge & Switch'
- : 'Switch Provider'}
+ {archivedPredecessor && mergeChoice === 'merge' ? 'Merge & Switch' : 'Switch Provider'}
}
>
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
-
+
el?.focus()}
+ >
{/* Current Provider */}
-
+
Current Provider
{currentProviderIcon}
-
@@ -340,7 +355,10 @@ export function SwitchProviderModal({
{/* Target Provider Selection */}
-
+
Select target provider:
{providers.length === 0 ? (
-
+
No other providers detected
) : (
@@ -426,7 +441,9 @@ export function SwitchProviderModal({
{isAvailable ? 'available' : 'Not Installed'}
@@ -448,12 +465,21 @@ export function SwitchProviderModal({
}}
>
-
+
-
Previous {getAgentDisplayName(selectedProvider)} session found
+
+ Previous {getAgentDisplayName(selectedProvider)} session found
+
- “{archivedPredecessor.name || 'Unnamed Agent'}” was previously on {getAgentDisplayName(selectedProvider)} before switching to {currentProviderName}
- {archivedPredecessor.migratedAt ? ` ${formatRelativeTime(archivedPredecessor.migratedAt)}` : ''}.
+ “{archivedPredecessor.name || 'Unnamed Agent'}” was previously on{' '}
+ {getAgentDisplayName(selectedProvider)} before switching to {currentProviderName}
+ {archivedPredecessor.migratedAt
+ ? ` ${formatRelativeTime(archivedPredecessor.migratedAt)}`
+ : ''}
+ .
@@ -473,7 +499,8 @@ export function SwitchProviderModal({
Create new session
- Start fresh on {getAgentDisplayName(selectedProvider)} with transferred context (creates a new agent entry)
+ Start fresh on {getAgentDisplayName(selectedProvider)} with transferred context
+ (creates a new agent entry)
@@ -490,7 +517,8 @@ export function SwitchProviderModal({
Merge & update existing session
- Reactivate the archived {getAgentDisplayName(selectedProvider)} session and append current context to it
+ Reactivate the archived {getAgentDisplayName(selectedProvider)} session and
+ append current context to it
@@ -500,7 +528,10 @@ export function SwitchProviderModal({
{/* Options */}
-
+
Options
From 5efd4124c9552117a3591903a60b7f2aa1e4aa59 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 1 Mar 2026 00:15:07 -0500
Subject: [PATCH 56/59] MAESTRO: replace console.error and empty .catch with
Sentry reporting in account flows
Replace console.error calls and silently-swallowed .catch(() => {}) with
Sentry.captureException reporting with contextual metadata across all
account/provider flows: reconciliation, account switching, provider
switching, session merging, and account assignment.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/App.tsx | 422 +++++++++++-------
src/renderer/components/AccountSelector.tsx | 42 +-
src/renderer/components/MergeSessionModal.tsx | 86 ++--
.../components/SwitchProviderModal.tsx | 8 +-
4 files changed, 352 insertions(+), 206 deletions(-)
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index cb1f20620..40e339dcb 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -23,6 +23,7 @@ import {
type SendToAgentOptions,
} from './components/AppModals';
import { DEFAULT_BATCH_PROMPT } from './components/BatchRunnerModal';
+import * as Sentry from '@sentry/electron/renderer';
import { ErrorBoundary } from './components/ErrorBoundary';
import { MainPanel, type MainPanelHandle } from './components/MainPanel';
import { AppOverlays } from './components/AppOverlays';
@@ -1257,49 +1258,65 @@ function MaestroConsoleInner() {
// Reconcile account assignments after session restore (ACCT-MUX-13)
// This validates accounts still exist and updates customEnvVars accordingly
- if (useSettingsStore.getState().encoreFeatures.virtuosos) try {
- const activeIds = restoredSessions.map(s => s.id);
- const reconciliation = await window.maestro.accounts.reconcileSessions(activeIds);
- if (reconciliation.success && reconciliation.corrections.length > 0) {
- setSessions(prev => prev.map(session => {
- const correction = reconciliation.corrections.find(c => c.sessionId === session.id);
- if (!correction) return session;
-
- if (correction.status === 'removed') {
- // Account was removed — clear session's account fields and CLAUDE_CONFIG_DIR
- const cleanedEnvVars = { ...session.customEnvVars };
- delete cleanedEnvVars.CLAUDE_CONFIG_DIR;
- return {
- ...session,
- accountId: undefined,
- accountName: undefined,
- customEnvVars: Object.keys(cleanedEnvVars).length > 0 ? cleanedEnvVars : undefined,
- };
- } else if (correction.configDir && session.accountId) {
- // Account exists — ensure CLAUDE_CONFIG_DIR is current
- return {
- ...session,
- accountId: correction.accountId ?? undefined,
- accountName: correction.accountName ?? undefined,
- customEnvVars: {
- ...session.customEnvVars,
- CLAUDE_CONFIG_DIR: correction.configDir,
- },
- };
+ if (useSettingsStore.getState().encoreFeatures.virtuosos)
+ try {
+ const activeIds = restoredSessions.map((s) => s.id);
+ const reconciliation = await window.maestro.accounts.reconcileSessions(activeIds);
+ if (reconciliation.success && reconciliation.corrections.length > 0) {
+ setSessions((prev) =>
+ prev.map((session) => {
+ const correction = reconciliation.corrections.find(
+ (c) => c.sessionId === session.id
+ );
+ if (!correction) return session;
+
+ if (correction.status === 'removed') {
+ // Account was removed — clear session's account fields and CLAUDE_CONFIG_DIR
+ const cleanedEnvVars = { ...session.customEnvVars };
+ delete cleanedEnvVars.CLAUDE_CONFIG_DIR;
+ return {
+ ...session,
+ accountId: undefined,
+ accountName: undefined,
+ customEnvVars:
+ Object.keys(cleanedEnvVars).length > 0 ? cleanedEnvVars : undefined,
+ };
+ } else if (correction.configDir && session.accountId) {
+ // Account exists — ensure CLAUDE_CONFIG_DIR is current
+ return {
+ ...session,
+ accountId: correction.accountId ?? undefined,
+ accountName: correction.accountName ?? undefined,
+ customEnvVars: {
+ ...session.customEnvVars,
+ CLAUDE_CONFIG_DIR: correction.configDir,
+ },
+ };
+ }
+ return session;
+ })
+ );
+ }
+ // Re-register assignments for sessions that have accountId but were
+ // created before the assign() call was added to session creation
+ for (const session of restoredSessions) {
+ if (session.accountId && session.toolType === 'claude-code') {
+ window.maestro.accounts.assign(session.id, session.accountId).catch((err) => {
+ Sentry.captureException(err, {
+ extra: {
+ operation: 'account:reconcileAssign',
+ sessionId: session.id,
+ accountId: session.accountId,
+ },
+ });
+ });
}
- return session;
- }));
- }
- // Re-register assignments for sessions that have accountId but were
- // created before the assign() call was added to session creation
- for (const session of restoredSessions) {
- if (session.accountId && session.toolType === 'claude-code') {
- window.maestro.accounts.assign(session.id, session.accountId).catch(() => {});
}
+ } catch (reconcileError) {
+ Sentry.captureException(reconcileError, {
+ extra: { operation: 'account:reconciliation' },
+ });
}
- } catch (reconcileError) {
- console.error('[App] Account reconciliation failed:', reconcileError);
- }
// For remote (SSH) sessions, fetch git info in background to avoid blocking
// startup on SSH connection timeouts. This runs after UI is shown.
@@ -1652,9 +1669,10 @@ function MaestroConsoleInner() {
notifyToast({
type: 'success',
title: 'Virtuoso Recovered',
- message: data.recoveredCount === 1
- ? 'Virtuoso is available again'
- : `${data.recoveredCount} virtuosos are available again`,
+ message:
+ data.recoveredCount === 1
+ ? 'Virtuoso is available again'
+ : `${data.recoveredCount} virtuosos are available again`,
duration: 8_000,
});
@@ -1727,7 +1745,7 @@ function MaestroConsoleInner() {
if (!encoreFeatures.virtuosos) return;
const unsubAssigned = window.maestro.accounts.onAssigned((data) => {
setSessions((prev) =>
- prev.map(s => {
+ prev.map((s) => {
if (!data.sessionId.startsWith(s.id)) return s;
return { ...s, accountId: data.accountId, accountName: data.accountName };
})
@@ -1740,18 +1758,27 @@ function MaestroConsoleInner() {
useEffect(() => {
if (!encoreFeatures.virtuosos) return;
const unsubRespawn = window.maestro.accounts.onSwitchRespawn(async (data) => {
- const { sessionId: switchSessionId, toAccountId, toAccountName, configDir, lastPrompt, reason } = data;
+ const {
+ sessionId: switchSessionId,
+ toAccountId,
+ toAccountName,
+ configDir,
+ lastPrompt,
+ reason,
+ } = data;
// Find the session that needs respawning (match by base session ID)
- const session = sessionsRef.current.find(s => switchSessionId.startsWith(s.id));
+ const session = sessionsRef.current.find((s) => switchSessionId.startsWith(s.id));
if (!session) {
- console.error('[AccountSwitch] Session not found for respawn:', switchSessionId);
+ Sentry.captureException(new Error('[AccountSwitch] Session not found for respawn'), {
+ extra: { operation: 'account:switchRespawn', switchSessionId },
+ });
return;
}
// Update session with new account info and CLAUDE_CONFIG_DIR
setSessions((prev) =>
- prev.map(s => {
+ prev.map((s) => {
if (s.id !== session.id) return s;
return {
...s,
@@ -1769,7 +1796,13 @@ function MaestroConsoleInner() {
// Get agent config for respawn
const agent = await window.maestro.agents.get(session.toolType);
if (!agent) {
- console.error('[AccountSwitch] Agent not found for respawn:', session.toolType);
+ Sentry.captureException(new Error('[AccountSwitch] Agent not found for respawn'), {
+ extra: {
+ operation: 'account:switchRespawn',
+ toolType: session.toolType,
+ sessionId: session.id,
+ },
+ });
return;
}
@@ -1816,7 +1849,9 @@ function MaestroConsoleInner() {
duration: 5_000,
});
} catch (error) {
- console.error('[AccountSwitch] Failed to respawn agent:', error);
+ Sentry.captureException(error, {
+ extra: { operation: 'account:switchRespawn', sessionId: session.id, toAccountId },
+ });
notifyToast({
type: 'error',
title: 'Account Switch Failed',
@@ -1847,7 +1882,14 @@ function MaestroConsoleInner() {
automatic: true,
});
} catch (error) {
- console.error('[AccountSwitch] Auto-switch execution failed:', error);
+ Sentry.captureException(error, {
+ extra: {
+ operation: 'account:autoSwitchExecution',
+ switchSessionId,
+ fromAccountId,
+ toAccountId,
+ },
+ });
}
});
@@ -1895,7 +1937,7 @@ function MaestroConsoleInner() {
const cleanup = window.maestro.providers.onFailoverSuggest(async (suggestion) => {
// Find the session
- const session = sessionsRef.current.find(s => s.id === suggestion.sessionId);
+ const session = sessionsRef.current.find((s) => s.id === suggestion.sessionId);
if (!session) return;
// Load provider switch config from settings
@@ -4893,7 +4935,7 @@ You are taking over this conversation. Based on the context above, provide a bri
remoteId: string | null;
workingDirOverride?: string;
},
- accountId?: string,
+ accountId?: string
) => {
// Update session fields immediately
setSessions((prev) =>
@@ -4916,34 +4958,56 @@ You are taking over this conversation. Based on the context above, provide a bri
// Handle account change: resolve name immediately, then trigger switch/assign
if (accountId) {
- const currentSession = sessionsRef.current.find(s => s.id === sessionId);
+ const currentSession = sessionsRef.current.find((s) => s.id === sessionId);
const fromAccountId = currentSession?.accountId;
// Resolve account name and update session right away
- window.maestro.accounts.list().then((accounts: any[]) => {
- const account = accounts.find((a: any) => a.id === accountId);
- if (account) {
- setSessions((prev) =>
- prev.map((s) => {
- if (s.id !== sessionId) return s;
- return { ...s, accountId, accountName: account.name };
- })
- );
- }
- }).catch(() => {});
+ window.maestro.accounts
+ .list()
+ .then((accounts: any[]) => {
+ const account = accounts.find((a: any) => a.id === accountId);
+ if (account) {
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id !== sessionId) return s;
+ return { ...s, accountId, accountName: account.name };
+ })
+ );
+ }
+ })
+ .catch((err) => {
+ Sentry.captureException(err, {
+ extra: { operation: 'account:resolveNameOnSwitch', sessionId, accountId },
+ });
+ });
if (fromAccountId && fromAccountId !== accountId) {
// Full switch: kills running process, reassigns, respawns with new CLAUDE_CONFIG_DIR
- window.maestro.accounts.executeSwitch({
- sessionId,
- fromAccountId,
- toAccountId: accountId,
- reason: 'manual',
- automatic: false,
- }).catch((err: any) => console.error('Failed to execute account switch:', err));
+ window.maestro.accounts
+ .executeSwitch({
+ sessionId,
+ fromAccountId,
+ toAccountId: accountId,
+ reason: 'manual',
+ automatic: false,
+ })
+ .catch((err) => {
+ Sentry.captureException(err, {
+ extra: {
+ operation: 'account:executeSwitch',
+ sessionId,
+ fromAccountId,
+ toAccountId: accountId,
+ },
+ });
+ });
} else {
// First assignment or same account — just update registry
- window.maestro.accounts.assign(sessionId, accountId).catch(() => {});
+ window.maestro.accounts.assign(sessionId, accountId).catch((err) => {
+ Sentry.captureException(err, {
+ extra: { operation: 'account:assign', sessionId, accountId },
+ });
+ });
}
}
},
@@ -4952,87 +5016,100 @@ You are taking over this conversation. Based on the context above, provide a bri
// Provider Switch handlers
const handleSwitchProvider = useCallback((sessionId: string) => {
- const session = sessionsRef.current.find(s => s.id === sessionId);
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
if (session && session.toolType !== 'terminal') {
setSwitchProviderSession(session);
}
}, []);
- const handleConfirmProviderSwitch = useCallback(async (request: {
- targetProvider: ToolType;
- groomContext: boolean;
- archiveSource: boolean;
- mergeBackInto?: Session;
- }) => {
- if (!switchProviderSession) return;
+ const handleConfirmProviderSwitch = useCallback(
+ async (request: {
+ targetProvider: ToolType;
+ groomContext: boolean;
+ archiveSource: boolean;
+ mergeBackInto?: Session;
+ }) => {
+ if (!switchProviderSession) return;
- const activeTab = getActiveTab(switchProviderSession);
- if (!activeTab) return;
+ const activeTab = getActiveTab(switchProviderSession);
+ if (!activeTab) return;
- const result = await switchProvider({
- sourceSession: switchProviderSession,
- sourceTabId: activeTab.id,
- targetProvider: request.targetProvider,
- groomContext: request.groomContext,
- archiveSource: request.archiveSource,
- mergeBackInto: request.mergeBackInto,
- });
+ const result = await switchProvider({
+ sourceSession: switchProviderSession,
+ sourceTabId: activeTab.id,
+ targetProvider: request.targetProvider,
+ groomContext: request.groomContext,
+ archiveSource: request.archiveSource,
+ mergeBackInto: request.mergeBackInto,
+ });
- if (result.success && result.newSession) {
- if (result.mergedBack && request.mergeBackInto) {
- // Merge-back: replace the archived session with the reactivated one
- setSessions(prev => prev.map(s =>
- s.id === request.mergeBackInto!.id ? result.newSession! : s
- ));
- } else {
- // Always-new: add the new session to state
- setSessions(prev => [...prev, result.newSession!]);
- }
+ if (result.success && result.newSession) {
+ if (result.mergedBack && request.mergeBackInto) {
+ // Merge-back: replace the archived session with the reactivated one
+ setSessions((prev) =>
+ prev.map((s) => (s.id === request.mergeBackInto!.id ? result.newSession! : s))
+ );
+ } else {
+ // Always-new: add the new session to state
+ setSessions((prev) => [...prev, result.newSession!]);
+ }
- // Mark source as archived if requested
- if (request.archiveSource) {
- setSessions(prev => prev.map(s =>
- s.id === switchProviderSession.id
- ? {
- ...s,
- archivedByMigration: true,
- migratedToSessionId: result.newSessionId,
- }
- : s
- ));
- }
+ // Mark source as archived if requested
+ if (request.archiveSource) {
+ setSessions((prev) =>
+ prev.map((s) =>
+ s.id === switchProviderSession.id
+ ? {
+ ...s,
+ archivedByMigration: true,
+ migratedToSessionId: result.newSessionId,
+ }
+ : s
+ )
+ );
+ }
- // Clear provider error tracking for source session
- window.maestro.providers.clearSessionErrors(switchProviderSession.id).catch(() => {});
+ // Clear provider error tracking for source session
+ window.maestro.providers.clearSessionErrors(switchProviderSession.id).catch((err) => {
+ Sentry.captureException(err, {
+ extra: {
+ operation: 'provider:clearSessionErrors',
+ sessionId: switchProviderSession.id,
+ },
+ });
+ });
- // Navigate to the new/reactivated session
- setActiveSessionId(result.newSessionId!);
+ // Navigate to the new/reactivated session
+ setActiveSessionId(result.newSessionId!);
- // Show success toast
- const action = result.mergedBack ? 'Merged back to' : 'Switched to';
- notifyToast({
- type: 'success',
- title: 'Provider Switched',
- message: `${action} ${getAgentDisplayName(request.targetProvider)}`,
- duration: 5_000,
- });
- }
+ // Show success toast
+ const action = result.mergedBack ? 'Merged back to' : 'Switched to';
+ notifyToast({
+ type: 'success',
+ title: 'Provider Switched',
+ message: `${action} ${getAgentDisplayName(request.targetProvider)}`,
+ duration: 5_000,
+ });
+ }
- // Close the modal
- setSwitchProviderSession(null);
- }, [switchProviderSession, switchProvider, setActiveSessionId]);
+ // Close the modal
+ setSwitchProviderSession(null);
+ },
+ [switchProviderSession, switchProvider, setActiveSessionId]
+ );
// Unarchive handlers
const handleUnarchive = useCallback((sessionId: string) => {
- const session = sessionsRef.current.find(s => s.id === sessionId);
+ const session = sessionsRef.current.find((s) => s.id === sessionId);
if (!session || !session.archivedByMigration) return;
// Check for conflict: another non-archived session with the same name AND toolType
const conflicting = sessionsRef.current.find(
- s => s.id !== sessionId
- && s.toolType === session.toolType
- && s.name === session.name
- && !s.archivedByMigration
+ (s) =>
+ s.id !== sessionId &&
+ s.toolType === session.toolType &&
+ s.name === session.name &&
+ !s.archivedByMigration
);
if (conflicting) {
@@ -5042,11 +5119,13 @@ You are taking over this conversation. Based on the context above, provide a bri
});
} else {
// No conflict — directly unarchive
- setSessions(prev => prev.map(s =>
- s.id === sessionId
- ? { ...s, archivedByMigration: false, migratedToSessionId: undefined }
- : s
- ));
+ setSessions((prev) =>
+ prev.map((s) =>
+ s.id === sessionId
+ ? { ...s, archivedByMigration: false, migratedToSessionId: undefined }
+ : s
+ )
+ );
notifyToast({
type: 'success',
title: 'Agent Unarchived',
@@ -5060,15 +5139,17 @@ You are taking over this conversation. Based on the context above, provide a bri
if (!unarchiveConflictState) return;
const { archivedSession, conflictingSession } = unarchiveConflictState;
- setSessions(prev => prev.map(s => {
- if (s.id === archivedSession.id) {
- return { ...s, archivedByMigration: false, migratedToSessionId: undefined };
- }
- if (s.id === conflictingSession.id) {
- return { ...s, archivedByMigration: true };
- }
- return s;
- }));
+ setSessions((prev) =>
+ prev.map((s) => {
+ if (s.id === archivedSession.id) {
+ return { ...s, archivedByMigration: false, migratedToSessionId: undefined };
+ }
+ if (s.id === conflictingSession.id) {
+ return { ...s, archivedByMigration: true };
+ }
+ return s;
+ })
+ );
notifyToast({
type: 'success',
@@ -5084,17 +5165,25 @@ You are taking over this conversation. Based on the context above, provide a bri
if (!unarchiveConflictState) return;
const { archivedSession, conflictingSession } = unarchiveConflictState;
- setSessions(prev => prev
- .filter(s => s.id !== conflictingSession.id)
- .map(s =>
- s.id === archivedSession.id
- ? { ...s, archivedByMigration: false, migratedToSessionId: undefined }
- : s
- )
+ setSessions((prev) =>
+ prev
+ .filter((s) => s.id !== conflictingSession.id)
+ .map((s) =>
+ s.id === archivedSession.id
+ ? { ...s, archivedByMigration: false, migratedToSessionId: undefined }
+ : s
+ )
);
// Kill process for deleted session if running
- window.maestro.process.kill(conflictingSession.id).catch(() => {});
+ window.maestro.process.kill(conflictingSession.id).catch((err) => {
+ Sentry.captureException(err, {
+ extra: {
+ operation: 'account:killConflictingOnUnarchive',
+ sessionId: conflictingSession.id,
+ },
+ });
+ });
notifyToast({
type: 'success',
@@ -5893,12 +5982,23 @@ You are taking over this conversation. Based on the context above, provide a bri
// Pre-assign account for Claude Code sessions if accounts are configured
if (encoreFeatures.virtuosos && newSession.toolType === 'claude-code') {
try {
- const defaultAccount = await window.maestro.accounts.getDefault() as { id: string; name: string } | null;
+ const defaultAccount = (await window.maestro.accounts.getDefault()) as {
+ id: string;
+ name: string;
+ } | null;
if (defaultAccount) {
newSession.accountId = defaultAccount.id;
newSession.accountName = defaultAccount.name;
// Register assignment with main process so usage listener tracks this session
- window.maestro.accounts.assign(newId, defaultAccount.id).catch(() => {});
+ window.maestro.accounts.assign(newId, defaultAccount.id).catch((err) => {
+ Sentry.captureException(err, {
+ extra: {
+ operation: 'account:assignOnCreate',
+ sessionId: newId,
+ accountId: defaultAccount.id,
+ },
+ });
+ });
}
} catch {
// Accounts not configured or unavailable — proceed without assignment
@@ -9103,7 +9203,11 @@ You are taking over this conversation. Based on the context above, provide a bri
onCloseEditAgentModal={handleCloseEditAgentModal}
onSaveEditAgent={handleSaveEditAgent}
editAgentSession={editAgentSession}
- onSwitchProviderFromEdit={encoreFeatures.virtuosos && editAgentSession ? () => handleSwitchProvider(editAgentSession.id) : undefined}
+ onSwitchProviderFromEdit={
+ encoreFeatures.virtuosos && editAgentSession
+ ? () => handleSwitchProvider(editAgentSession.id)
+ : undefined
+ }
renameSessionModalOpen={renameInstanceModalOpen}
renameSessionValue={renameInstanceValue}
setRenameSessionValue={setRenameInstanceValue}
diff --git a/src/renderer/components/AccountSelector.tsx b/src/renderer/components/AccountSelector.tsx
index c26936267..4bd6301a4 100644
--- a/src/renderer/components/AccountSelector.tsx
+++ b/src/renderer/components/AccountSelector.tsx
@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useRef, useCallback } from 'react';
+import * as Sentry from '@sentry/electron/renderer';
import { User, ChevronDown, Settings } from 'lucide-react';
import type { Theme } from '../types';
import type { AccountProfile } from '../../shared/account-types';
@@ -47,7 +48,7 @@ export function AccountSelector({
onManageAccounts,
compact = false,
}: AccountSelectorProps) {
- const virtuososEnabled = useSettingsStore(state => state.encoreFeatures.virtuosos);
+ const virtuososEnabled = useSettingsStore((state) => state.encoreFeatures.virtuosos);
const [accounts, setAccounts] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
@@ -61,10 +62,12 @@ export function AccountSelector({
const list = (await window.maestro.accounts.list()) as AccountProfile[];
if (!cancelled) setAccounts(list);
} catch (err) {
- console.warn('[AccountSelector] Failed to fetch accounts:', err);
+ Sentry.captureException(err, { extra: { operation: 'account:fetchAccountList' } });
}
})();
- return () => { cancelled = true; };
+ return () => {
+ cancelled = true;
+ };
}, [isOpen, currentAccountId]);
// Close dropdown on outside click
@@ -93,7 +96,8 @@ export function AccountSelector({
}, [isOpen]);
const currentAccount = accounts.find((a) => a.id === currentAccountId);
- const displayName = currentAccount?.name ?? currentAccount?.email ?? currentAccountName ?? 'No Virtuoso';
+ const displayName =
+ currentAccount?.name ?? currentAccount?.email ?? currentAccountName ?? 'No Virtuoso';
const handleSelect = useCallback(
(accountId: string) => {
@@ -117,7 +121,9 @@ export function AccountSelector({
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full cursor-pointer transition-all hover:brightness-125"
style={{
color: currentAccountId ? theme.colors.textMain : theme.colors.textDim,
- backgroundColor: currentAccountId ? `${theme.colors.accent}20` : `${theme.colors.border}30`,
+ backgroundColor: currentAccountId
+ ? `${theme.colors.accent}20`
+ : `${theme.colors.border}30`,
border: currentAccountId
? `1px solid ${theme.colors.accent}50`
: `1px solid ${theme.colors.border}60`,
@@ -208,16 +214,23 @@ export function AccountSelector({
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, usage.usagePercent)}%`,
- backgroundColor: usage.usagePercent >= 95
- ? theme.colors.error
- : usage.usagePercent >= 80
- ? theme.colors.warning
- : theme.colors.accent,
+ backgroundColor:
+ usage.usagePercent >= 95
+ ? theme.colors.error
+ : usage.usagePercent >= 80
+ ? theme.colors.warning
+ : theme.colors.accent,
}}
/>
-
-
{formatTokenCount(usage.totalTokens)} / {formatTokenCount(usage.limitTokens)}
+
+
+ {formatTokenCount(usage.totalTokens)} /{' '}
+ {formatTokenCount(usage.limitTokens)}
+
{formatTimeRemaining(usage.timeRemainingMs)}
@@ -242,10 +255,7 @@ export function AccountSelector({
{/* Manage Accounts link */}
{onManageAccounts && (
-
+
{
diff --git a/src/renderer/components/MergeSessionModal.tsx b/src/renderer/components/MergeSessionModal.tsx
index 5d5c972c4..519e192a6 100644
--- a/src/renderer/components/MergeSessionModal.tsx
+++ b/src/renderer/components/MergeSessionModal.tsx
@@ -15,6 +15,7 @@
*/
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
+import * as Sentry from '@sentry/electron/renderer';
import { Search, ChevronRight, ChevronDown, GitMerge, Clipboard, Check, X } from 'lucide-react';
import type { Theme, Session, AITab } from '../types';
import type { MergeResult } from '../types/contextMerge';
@@ -459,7 +460,14 @@ export function MergeSessionModal({
await onMerge(target.sessionId, target.tabId, options);
onClose();
} catch (error) {
- console.error('Merge failed:', error);
+ Sentry.captureException(error, {
+ extra: {
+ operation: 'session:merge',
+ viewMode,
+ targetSessionId: target.sessionId,
+ targetTabId: target.tabId,
+ },
+ });
} finally {
setIsMerging(false);
}
@@ -906,7 +914,15 @@ export function MergeSessionModal({
)}
{item.accountId && (
-
+
({item.accountName || item.accountId})
)}
@@ -961,21 +977,25 @@ export function MergeSessionModal({
{sourceSession?.accountId && (
-
-
+
+
Account: {sourceSession.accountName || sourceSession.accountId}
)}
@@ -1024,20 +1044,26 @@ export function MergeSessionModal({
{/* Account mismatch warning */}
{(() => {
const target = viewMode === 'paste' ? pastedIdMatch : selectedTarget;
- if (sourceSession?.accountId && target?.accountId
- && sourceSession.accountId !== target.accountId) {
+ if (
+ sourceSession?.accountId &&
+ target?.accountId &&
+ sourceSession.accountId !== target.accountId
+ ) {
return (
-
- Note: Source and target sessions use different accounts
- ({sourceSession.accountName || sourceSession.accountId} → {target.accountName || target.accountId}).
- Session files are shared via symlinks, so this merge should work seamlessly.
+
+ Note: Source and target sessions use different accounts (
+ {sourceSession.accountName || sourceSession.accountId} →{' '}
+ {target.accountName || target.accountId}). Session files are shared via symlinks,
+ so this merge should work seamlessly.
);
}
diff --git a/src/renderer/components/SwitchProviderModal.tsx b/src/renderer/components/SwitchProviderModal.tsx
index 0456b83ce..9acd362e7 100644
--- a/src/renderer/components/SwitchProviderModal.tsx
+++ b/src/renderer/components/SwitchProviderModal.tsx
@@ -16,6 +16,7 @@
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
+import * as Sentry from '@sentry/electron/renderer';
import { ArrowDown, Shuffle, Info } from 'lucide-react';
import type { Theme, Session, ToolType, AgentConfig } from '../types';
import type { ProviderSwitchBehavior } from '../../shared/account-types';
@@ -138,7 +139,12 @@ export function SwitchProviderModal({
setProviders(options);
} catch (err) {
- console.error('Failed to detect agents for provider switch:', err);
+ Sentry.captureException(err, {
+ extra: {
+ operation: 'provider:detectAgentsForSwitch',
+ sourceToolType: sourceSession.toolType,
+ },
+ });
}
})();
From 62695d10ffad1ca981acaf5391176a13ca6721e3 Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 1 Mar 2026 00:18:57 -0500
Subject: [PATCH 57/59] MAESTRO: remove unused archiveSource from
ProviderSwitchRequest interface
The archiveSource field was defined in ProviderSwitchRequest but never
used by the useProviderSwitch hook. Archiving is handled entirely by
App.tsx after the hook returns, using the modal callback's own request
parameter.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/App.tsx | 1 -
src/renderer/hooks/agent/useProviderSwitch.ts | 16 ++++------------
2 files changed, 4 insertions(+), 13 deletions(-)
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 40e339dcb..6b58320f7 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -5039,7 +5039,6 @@ You are taking over this conversation. Based on the context above, provide a bri
sourceTabId: activeTab.id,
targetProvider: request.targetProvider,
groomContext: request.groomContext,
- archiveSource: request.archiveSource,
mergeBackInto: request.mergeBackInto,
});
diff --git a/src/renderer/hooks/agent/useProviderSwitch.ts b/src/renderer/hooks/agent/useProviderSwitch.ts
index 6679921cc..365073f12 100644
--- a/src/renderer/hooks/agent/useProviderSwitch.ts
+++ b/src/renderer/hooks/agent/useProviderSwitch.ts
@@ -45,8 +45,6 @@ export interface ProviderSwitchRequest {
targetProvider: ToolType;
/** Whether to groom context for target provider */
groomContext: boolean;
- /** Whether to auto-archive source session after switch */
- archiveSource: boolean;
/**
* When set, reactivate this archived session instead of creating a new one.
* The groomed context from the source is appended to the target session's logs.
@@ -91,7 +89,7 @@ export interface UseProviderSwitchResult {
export function findArchivedPredecessor(
sessions: Session[],
currentSession: Session,
- targetProvider: ToolType,
+ targetProvider: ToolType
): Session | null {
let cursor: Session | undefined = currentSession;
const visited = new Set();
@@ -109,7 +107,7 @@ export function findArchivedPredecessor(
}
if (cursor.migratedFromSessionId) {
- cursor = sessions.find(s => s.id === cursor!.migratedFromSessionId);
+ cursor = sessions.find((s) => s.id === cursor!.migratedFromSessionId);
} else {
break;
}
@@ -142,7 +140,6 @@ const INITIAL_PROGRESS: GroomingProgress = {
* sourceTabId: activeTabId,
* targetProvider: 'codex',
* groomContext: true,
- * archiveSource: true,
* });
*
* if (result.success && result.newSession) {
@@ -263,9 +260,7 @@ export function useProviderSwitch(): UseProviderSwitchResult {
});
const sessionDisplayName =
- sourceSession.name ||
- sourceSession.projectRoot.split('/').pop() ||
- 'Unnamed Session';
+ sourceSession.name || sourceSession.projectRoot.split('/').pop() || 'Unnamed Session';
const sourceContext = extractTabContext(sourceTab, sessionDisplayName, sourceSession);
@@ -286,10 +281,7 @@ export function useProviderSwitch(): UseProviderSwitchResult {
},
});
- const transferPrompt = buildContextTransferPrompt(
- sourceSession.toolType,
- targetProvider
- );
+ const transferPrompt = buildContextTransferPrompt(sourceSession.toolType, targetProvider);
const groomingRequest: MergeRequest = {
sources: [sourceContext],
From d7b588d159c61a7e0b0d4338930cced47da966ee Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 1 Mar 2026 00:25:10 -0500
Subject: [PATCH 58/59] MAESTRO: report agent availability check failures to
Sentry and fail fast
Separated the IPC call from the availability check in useProviderSwitch so
infrastructure failures (window.maestro.agents.get throwing) are reported to
Sentry with context metadata and re-thrown as a user-facing error, instead of
being silently swallowed by console.warn. The !agentStatus?.available check
now correctly propagates to the outer error handler.
Co-Authored-By: Claude Opus 4.6
---
src/renderer/hooks/agent/useProviderSwitch.ts | 27 +++++++++++++------
1 file changed, 19 insertions(+), 8 deletions(-)
diff --git a/src/renderer/hooks/agent/useProviderSwitch.ts b/src/renderer/hooks/agent/useProviderSwitch.ts
index 365073f12..75877d5c5 100644
--- a/src/renderer/hooks/agent/useProviderSwitch.ts
+++ b/src/renderer/hooks/agent/useProviderSwitch.ts
@@ -18,6 +18,7 @@
*/
import { useCallback, useRef } from 'react';
+import * as Sentry from '@sentry/electron/renderer';
import type { Session, LogEntry, ToolType } from '../../types';
import type { GroomingProgress, MergeRequest } from '../../types/contextMerge';
import type { TransferState, TransferLastRequest } from '../../stores/operationStore';
@@ -234,16 +235,26 @@ export function useProviderSwitch(): UseProviderSwitchResult {
}
// Verify target agent is available
+ let agentStatus;
try {
- const agentStatus = await window.maestro.agents.get(targetProvider);
- if (!agentStatus?.available) {
- throw new Error(
- `${getAgentDisplayName(targetProvider)} is not available. Please install and configure it first.`
- );
- }
+ agentStatus = await window.maestro.agents.get(targetProvider);
} catch (agentCheckError) {
- // If we can't check, log warning but continue
- console.warn('Could not verify agent availability:', agentCheckError);
+ Sentry.captureException(agentCheckError, {
+ extra: {
+ operation: 'agent-availability-check',
+ targetProvider,
+ sourceAgent: sourceSession.toolType,
+ },
+ });
+ throw new Error(
+ `Failed to verify ${getAgentDisplayName(targetProvider)} availability. Please try again.`
+ );
+ }
+
+ if (!agentStatus?.available) {
+ throw new Error(
+ `${getAgentDisplayName(targetProvider)} is not available. Please install and configure it first.`
+ );
}
if (cancelledRef.current) {
From c89b2443f2aa8fb6a321b1e98c126387affa230e Mon Sep 17 00:00:00 2001
From: openasocket
Date: Sun, 1 Mar 2026 00:51:26 -0500
Subject: [PATCH 59/59] MAESTRO: add accountId fallback to SessionList tooltip
When accountName is missing but accountId exists, display accountId
as fallback in the agent tooltip. Matches the existing pattern used
in the context menu (line 258).
Co-Authored-By: Claude Opus 4.6
---
src/renderer/components/SessionList.tsx | 21 ++++++++++++++++-----
1 file changed, 16 insertions(+), 5 deletions(-)
diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx
index 538693e1e..f251f1a27 100644
--- a/src/renderer/components/SessionList.tsx
+++ b/src/renderer/components/SessionList.tsx
@@ -1007,9 +1007,12 @@ const SessionTooltipContent = memo(function SessionTooltipContent({
{session.state} • {session.toolType}
{session.sessionSshRemoteConfig?.enabled ? ' (SSH)' : ''}
- {session.accountName && (
+ {(session.accountName || session.accountId) && (
- Account: {session.accountName}
+ Account:{' '}
+
+ {session.accountName || session.accountId}
+
)}
@@ -1714,7 +1717,9 @@ function SessionListInner(props: SessionListProps) {
gitFileCount={getFileCount(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
- accountUsagePercent={session.accountId ? accountUsageMetrics[session.accountId]?.usagePercent : undefined}
+ accountUsagePercent={
+ session.accountId ? accountUsageMetrics[session.accountId]?.usagePercent : undefined
+ }
onSelect={selectHandlers.get(session.id)!}
onDragStart={dragStartHandlers.get(session.id)!}
onDragOver={handleDragOver}
@@ -1777,7 +1782,11 @@ function SessionListInner(props: SessionListProps) {
gitFileCount={getFileCount(child.id)}
isInBatch={activeBatchSessionIds.includes(child.id)}
jumpNumber={getSessionJumpNumber(child.id)}
- accountUsagePercent={child.accountId ? accountUsageMetrics[child.accountId]?.usagePercent : undefined}
+ accountUsagePercent={
+ child.accountId
+ ? accountUsageMetrics[child.accountId]?.usagePercent
+ : undefined
+ }
onSelect={selectHandlers.get(child.id)!}
onDragStart={dragStartHandlers.get(child.id)!}
onContextMenu={contextMenuHandlers.get(child.id)!}
@@ -3149,7 +3158,9 @@ function SessionListInner(props: SessionListProps) {
? () => onCreateGroupAndMove(contextMenuSession.id)
: createNewGroup
}
- onSwitchProvider={onSwitchProvider ? () => onSwitchProvider(contextMenuSession.id) : undefined}
+ onSwitchProvider={
+ onSwitchProvider ? () => onSwitchProvider(contextMenuSession.id) : undefined
+ }
onUnarchive={onUnarchive ? () => onUnarchive(contextMenuSession.id) : undefined}
/>
)}