diff --git a/CLAUDE-PERFORMANCE.md b/CLAUDE-PERFORMANCE.md index 00525dd7..07ac6861 100644 --- a/CLAUDE-PERFORMANCE.md +++ b/CLAUDE-PERFORMANCE.md @@ -230,3 +230,34 @@ useEffect(() => { return () => document.removeEventListener('click', handler); }, []); ``` + +## Performance Profiling + +**Exporting DevTools Performance traces:** + +The Chrome DevTools Performance panel's "Save profile" button fails in Electron with: +``` +NotAllowedError: The request is not allowed by the user agent or the platform in the current context. +``` + +This occurs because Electron 28 doesn't fully support the File System Access API (`showSaveFilePicker`). Full support was added in Electron 30+ ([electron/electron#41419](https://github.com/electron/electron/pull/41419)). + +**Workarounds:** + +1. **Launch with experimental flag** (enables FSAA): + ```bash + # macOS + /Applications/Maestro.app/Contents/MacOS/Maestro --enable-experimental-web-platform-features + + # Development + npm run dev -- --enable-experimental-web-platform-features + ``` + +2. **Use Maestro's native save dialog** (copy trace JSON from DevTools, then in renderer console): + ```javascript + navigator.clipboard.readText().then(data => + window.maestro.dialog.saveFile({ defaultPath: 'trace.json', content: data }) + ); + ``` + +3. **Right-click context menu** - Right-click on the flame graph and select "Save profile..." which may use a different code path. diff --git a/src/__tests__/main/ipc/handlers/claude.test.ts b/src/__tests__/main/ipc/handlers/claude.test.ts index 2983fe3a..e870b654 100644 --- a/src/__tests__/main/ipc/handlers/claude.test.ts +++ b/src/__tests__/main/ipc/handlers/claude.test.ts @@ -153,8 +153,9 @@ describe('Claude IPC handlers', () => { // Line 1422: ipcMain.handle('claude:registerSessionOrigin', ...) - Register session origin (user/auto) // Line 1438: ipcMain.handle('claude:updateSessionName', ...) - Update session name // Line 1459: ipcMain.handle('claude:updateSessionStarred', ...) - Update session starred status - // Line 1480: ipcMain.handle('claude:getSessionOrigins', ...) - Get session origins for a project - // Line 1488: ipcMain.handle('claude:getAllNamedSessions', ...) - Get all sessions with names + // Line 1461: ipcMain.handle('claude:updateSessionContextUsage', ...) - Update context usage percentage + // Line 1482: ipcMain.handle('claude:getSessionOrigins', ...) - Get session origins for a project + // Line 1490: ipcMain.handle('claude:getAllNamedSessions', ...) - Get all sessions with names const expectedChannels = [ 'claude:listSessions', 'claude:listSessionsPaginated', @@ -168,6 +169,7 @@ describe('Claude IPC handlers', () => { 'claude:registerSessionOrigin', 'claude:updateSessionName', 'claude:updateSessionStarred', + 'claude:updateSessionContextUsage', 'claude:getSessionOrigins', 'claude:getAllNamedSessions', ]; diff --git a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx index 52f57ec1..db27c67b 100644 --- a/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx +++ b/src/__tests__/renderer/components/AgentSessionsBrowser.test.tsx @@ -2180,6 +2180,8 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); + // buildUsageStats now only preserves cost (tokens are zeroed to avoid stale context display) + // The actual context usage will be looked up from session origins by handleResumeSession expect(onResumeSession).toHaveBeenCalledWith( 'session-1', expect.arrayContaining([ @@ -2189,8 +2191,8 @@ describe('AgentSessionsBrowser', () => { 'My Session', false, // not starred expect.objectContaining({ - inputTokens: 5000, - outputTokens: 2000, + inputTokens: 0, + outputTokens: 0, totalCostUsd: 0.15, }) ); @@ -2229,14 +2231,15 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); + // buildUsageStats now only preserves cost (tokens are zeroed to avoid stale context display) expect(onResumeSession).toHaveBeenCalledWith( 'session-1', expect.any(Array), undefined, true, // starred expect.objectContaining({ - inputTokens: 5000, - outputTokens: 2000, + inputTokens: 0, + outputTokens: 0, totalCostUsd: 0.15, }) ); @@ -2307,14 +2310,15 @@ describe('AgentSessionsBrowser', () => { await vi.runAllTimersAsync(); }); + // buildUsageStats now only preserves cost (tokens are zeroed to avoid stale context display) expect(onResumeSession).toHaveBeenCalledWith( 'session-1', [], // Empty messages for quick resume 'Quick Session', false, expect.objectContaining({ - inputTokens: 5000, - outputTokens: 2000, + inputTokens: 0, + outputTokens: 0, totalCostUsd: 0.15, }) ); diff --git a/src/__tests__/renderer/hooks/useAgentSessionManagement.test.ts b/src/__tests__/renderer/hooks/useAgentSessionManagement.test.ts index 694c9183..5eaf391f 100644 --- a/src/__tests__/renderer/hooks/useAgentSessionManagement.test.ts +++ b/src/__tests__/renderer/hooks/useAgentSessionManagement.test.ts @@ -297,7 +297,7 @@ describe('useAgentSessionManagement', () => { expect(updatedSession.inputMode).toBe('ai'); }); - it('skips origin lookup when metadata is already provided', async () => { + it('skips message fetch when messages are already provided', async () => { const activeSession = createMockSession({ projectRoot: '/test/project' }); const setSessions = vi.fn(); @@ -325,7 +325,9 @@ describe('useAgentSessionManagement', () => { await result.current.handleResumeSession('agent-789', providedMessages, 'Named Session', false); }); - expect(window.maestro.claude.getSessionOrigins).not.toHaveBeenCalled(); + // Origin lookup is still called to get contextUsage for context window persistence + expect(window.maestro.claude.getSessionOrigins).toHaveBeenCalled(); + // But message fetch should be skipped since messages were provided expect(window.maestro.agentSessions.read).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/shared/templateVariables.test.ts b/src/__tests__/shared/templateVariables.test.ts index de6f310e..33ef7069 100644 --- a/src/__tests__/shared/templateVariables.test.ts +++ b/src/__tests__/shared/templateVariables.test.ts @@ -64,6 +64,7 @@ describe('TEMPLATE_VARIABLES constant', () => { expect(variables).toContain('{{AGENT_PATH}}'); expect(variables).toContain('{{AGENT_GROUP}}'); expect(variables).toContain('{{AGENT_SESSION_ID}}'); + expect(variables).toContain('{{AGENT_HISTORY_PATH}}'); expect(variables).toContain('{{TAB_NAME}}'); expect(variables).toContain('{{TOOL_TYPE}}'); }); @@ -205,6 +206,22 @@ describe('substituteTemplateVariables', () => { expect(result1).toBe(result2); expect(result1).toBe('Aliased Name'); }); + + it('should replace {{AGENT_HISTORY_PATH}} with historyFilePath', () => { + const context = createTestContext({ + historyFilePath: '/Users/test/.config/Maestro/history/session-123.json', + }); + const result = substituteTemplateVariables('History: {{AGENT_HISTORY_PATH}}', context); + expect(result).toBe('History: /Users/test/.config/Maestro/history/session-123.json'); + }); + + it('should replace {{AGENT_HISTORY_PATH}} with empty string when historyFilePath is undefined', () => { + const context = createTestContext({ + historyFilePath: undefined, + }); + const result = substituteTemplateVariables('History: {{AGENT_HISTORY_PATH}}', context); + expect(result).toBe('History: '); + }); }); describe('Legacy Session Variables (backwards compatibility)', () => { diff --git a/src/main/agent-session-storage.ts b/src/main/agent-session-storage.ts index 5305f94e..bb5f1348 100644 --- a/src/main/agent-session-storage.ts +++ b/src/main/agent-session-storage.ts @@ -123,6 +123,8 @@ export interface SessionOriginInfo { origin: AgentSessionOrigin; sessionName?: string; starred?: boolean; + /** Last known context window usage percentage (0-100) for session resume */ + contextUsage?: number; } /** diff --git a/src/main/index.ts b/src/main/index.ts index 31485374..f381dc6a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -301,6 +301,7 @@ interface ClaudeSessionOriginInfo { origin: ClaudeSessionOrigin; sessionName?: string; // User-defined session name from Maestro starred?: boolean; // Whether the session is starred + contextUsage?: number; // Last known context window usage percentage (0-100) } interface ClaudeSessionOriginsData { // Map of projectPath -> { agentSessionId -> origin info } diff --git a/src/main/ipc/handlers/claude.ts b/src/main/ipc/handlers/claude.ts index 4094073e..36cd0ccc 100644 --- a/src/main/ipc/handlers/claude.ts +++ b/src/main/ipc/handlers/claude.ts @@ -111,6 +111,7 @@ interface ClaudeSessionOriginInfo { origin: ClaudeSessionOrigin; sessionName?: string; starred?: boolean; + contextUsage?: number; } interface ClaudeSessionOriginsData { @@ -1457,6 +1458,27 @@ export function registerClaudeHandlers(deps: ClaudeHandlerDependencies): void { } )); + ipcMain.handle('claude:updateSessionContextUsage', withIpcErrorLogging( + handlerOpts('updateSessionContextUsage', ORIGINS_LOG_CONTEXT), + async (projectPath: string, agentSessionId: string, contextUsage: number) => { + const origins = claudeSessionOriginsStore.get('origins', {}); + if (!origins[projectPath]) { + origins[projectPath] = {}; + } + const existing = origins[projectPath][agentSessionId]; + if (typeof existing === 'string') { + origins[projectPath][agentSessionId] = { origin: existing, contextUsage }; + } else if (existing) { + origins[projectPath][agentSessionId] = { ...existing, contextUsage }; + } else { + origins[projectPath][agentSessionId] = { origin: 'user', contextUsage }; + } + claudeSessionOriginsStore.set('origins', origins); + // Don't log - this updates frequently and would spam logs + return true; + } + )); + ipcMain.handle('claude:getSessionOrigins', withIpcErrorLogging( handlerOpts('getSessionOrigins', ORIGINS_LOG_CONTEXT), async (projectPath: string) => { diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 3e48b200..93521e53 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -87,6 +87,7 @@ interface ClaudeSessionOriginInfo { origin: ClaudeSessionOrigin; sessionName?: string; starred?: boolean; + contextUsage?: number; } interface ClaudeSessionOriginsData { origins: Record>; diff --git a/src/main/preload.ts b/src/main/preload.ts index b1b72f6b..54c4416f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -863,6 +863,9 @@ contextBridge.exposeInMainWorld('maestro', { logDeprecationWarning('updateSessionStarred'); return ipcRenderer.invoke('claude:updateSessionStarred', projectPath, agentSessionId, starred); }, + updateSessionContextUsage: (projectPath: string, agentSessionId: string, contextUsage: number) => { + return ipcRenderer.invoke('claude:updateSessionContextUsage', projectPath, agentSessionId, contextUsage); + }, getSessionOrigins: (projectPath: string) => { logDeprecationWarning('getSessionOrigins'); return ipcRenderer.invoke('claude:getSessionOrigins', projectPath); @@ -2348,7 +2351,8 @@ export interface MaestroAPI { registerSessionOrigin: (projectPath: string, agentSessionId: string, origin: 'user' | 'auto', sessionName?: string) => Promise; updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => Promise; updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => Promise; - getSessionOrigins: (projectPath: string) => Promise>; + updateSessionContextUsage: (projectPath: string, agentSessionId: string, contextUsage: number) => Promise; + getSessionOrigins: (projectPath: string) => Promise>; deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>; }; agentSessions: { diff --git a/src/main/storage/claude-session-storage.ts b/src/main/storage/claude-session-storage.ts index 680dcde0..ae6d84ae 100644 --- a/src/main/storage/claude-session-storage.ts +++ b/src/main/storage/claude-session-storage.ts @@ -46,6 +46,7 @@ type StoredOriginData = origin: AgentSessionOrigin; sessionName?: string; starred?: boolean; + contextUsage?: number; }; export interface ClaudeSessionOriginsData { @@ -1143,6 +1144,27 @@ export class ClaudeSessionStorage implements AgentSessionStorage { logger.debug(`Updated Claude session starred: ${agentSessionId} = ${starred}`, LOG_CONTEXT); } + /** + * Update the context usage percentage of a session + * This persists the last known context window usage so it can be restored on resume + */ + updateSessionContextUsage(projectPath: string, agentSessionId: string, contextUsage: number): void { + const origins = this.originsStore.get('origins', {}); + if (!origins[projectPath]) { + origins[projectPath] = {}; + } + const existing = origins[projectPath][agentSessionId]; + if (typeof existing === 'string') { + origins[projectPath][agentSessionId] = { origin: existing, contextUsage }; + } else if (existing) { + origins[projectPath][agentSessionId] = { ...existing, contextUsage }; + } else { + origins[projectPath][agentSessionId] = { origin: 'user', contextUsage }; + } + this.originsStore.set('origins', origins); + // Don't log this - it updates frequently and would spam logs + } + /** * Get all origin info for a project */ @@ -1160,6 +1182,7 @@ export class ClaudeSessionStorage implements AgentSessionStorage { origin: data.origin, sessionName: data.sessionName, starred: data.starred, + contextUsage: data.contextUsage, }; } } diff --git a/src/prompts/maestro-system-prompt.md b/src/prompts/maestro-system-prompt.md index 63327980..b4153758 100644 --- a/src/prompts/maestro-system-prompt.md +++ b/src/prompts/maestro-system-prompt.md @@ -18,6 +18,17 @@ Maestro is an Electron desktop application for managing multiple AI coding assis - **Current Directory:** {{CWD}} - **Git Branch:** {{GIT_BRANCH}} - **Session ID:** {{AGENT_SESSION_ID}} +- **History File:** {{AGENT_HISTORY_PATH}} + +## Task Recall + +Your session history is stored at `{{AGENT_HISTORY_PATH}}`. When you need context about previously completed tasks, read this JSON file and parse the `entries` array. Each entry contains: +- `summary`: Brief description of the task +- `timestamp`: When the task was completed (Unix ms) +- `type`: `AUTO` (automated) or `USER` (interactive) +- `success`: Whether the task succeeded + +To recall recent work, read the file and scan the most recent entries by timestamp. ## Auto-run Documents diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 780d0178..cc9ac24a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2247,6 +2247,22 @@ function MaestroConsoleInner() { batchedUpdater.updateContextUsage(actualSessionId, contextPercentage); batchedUpdater.updateCycleTokens(actualSessionId, usageStats.outputTokens); + // Persist context usage for Claude sessions so it survives app restart + // Only persist if we have a valid tab with an agent session ID + if (isClaudeUsage && tabId && sessionForUsage?.projectRoot) { + const tab = sessionForUsage.aiTabs?.find(t => t.id === tabId); + if (tab?.agentSessionId) { + // Fire and forget - don't await to avoid blocking the UI + window.maestro.claude.updateSessionContextUsage( + sessionForUsage.projectRoot, + tab.agentSessionId, + contextPercentage + ).catch(() => { + // Silently ignore errors - this is a best-effort persistence + }); + } + } + // Update persistent global stats (not batched - this is a separate concern) updateGlobalStatsRef.current({ totalInputTokens: usageStats.inputTokens, diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index b5fd5310..542b8711 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -502,14 +502,18 @@ export function AgentSessionsBrowser({ }; // Helper to build UsageStats from session data + // NOTE: Token counts from stored sessions are LIFETIME TOTALS, not current context. + // We only preserve the cost for display. Token fields are set to 0 so context window + // starts at 0% and gets updated when Claude Code sends fresh usage data. + // This fixes the bug where resumed sessions showed 100% context due to stale cumulative tokens. const buildUsageStats = useCallback((session: ClaudeSession): UsageStats | undefined => { - // Only build if we have token data - if (!session.inputTokens && !session.outputTokens) return undefined; + // Only build if we have cost data (tokens are intentionally zeroed) + if (!session.costUsd) return undefined; return { - inputTokens: session.inputTokens || 0, - outputTokens: session.outputTokens || 0, - cacheReadInputTokens: session.cacheReadTokens || 0, - cacheCreationInputTokens: session.cacheCreationTokens || 0, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, totalCostUsd: session.costUsd || 0, contextWindow: 200000, // Default Claude context window }; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 6f97b5dd..0bcddf78 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -810,7 +810,8 @@ interface MaestroAPI { registerSessionOrigin: (projectPath: string, agentSessionId: string, origin: 'user' | 'auto', sessionName?: string) => Promise; updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => Promise; updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => Promise; - getSessionOrigins: (projectPath: string) => Promise>; + updateSessionContextUsage: (projectPath: string, agentSessionId: string, contextUsage: number) => Promise; + getSessionOrigins: (projectPath: string) => Promise>; getAllNamedSessions: () => Promise 0) { + const contextWindow = finalUsageStats?.contextWindow || 200000; + finalUsageStats = { + inputTokens: Math.round(storedContextUsage * contextWindow / 100), + outputTokens: finalUsageStats?.outputTokens || 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + totalCostUsd: finalUsageStats?.totalCostUsd || 0, + contextWindow, + reasoningTokens: finalUsageStats?.reasoningTokens, + }; + } + // Update the session and switch to AI mode // IMPORTANT: Use functional update to get fresh session state and avoid race conditions setSessions(prev => prev.map(s => { @@ -234,7 +253,7 @@ export function useAgentSessionManagement( logs: messages, name, starred: isStarred, - usageStats, + usageStats: finalUsageStats, saveToHistory: defaultSaveToHistory, showThinking: defaultShowThinking }); diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts index b44be8ca..23699ad8 100644 --- a/src/renderer/hooks/input/useInputProcessing.ts +++ b/src/renderer/hooks/input/useInputProcessing.ts @@ -729,6 +729,14 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces } } + // Get history file path for task recall + let historyFilePath: string | undefined; + try { + historyFilePath = await window.maestro.history.getFilePath(freshSession.id) || undefined; + } catch { + // Ignore history errors + } + // Substitute template variables in the system prompt console.log('[useInputProcessing] Template substitution context:', { sessionId: freshSession.id, @@ -737,10 +745,12 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces fullPath: freshSession.fullPath, cwd: freshSession.cwd, parentSessionId: freshSession.parentSessionId, + historyFilePath, }); const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, { session: freshSession, gitBranch, + historyFilePath, }); // Prepend system prompt to user's message diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts index acd6a074..27706e23 100644 --- a/src/shared/templateVariables.ts +++ b/src/shared/templateVariables.ts @@ -8,6 +8,7 @@ * {{AGENT_PATH}} - Agent home directory path (full path to project) * {{AGENT_GROUP}} - Agent's group name (if grouped) * {{AGENT_SESSION_ID}} - Agent session ID (for conversation continuity) + * {{AGENT_HISTORY_PATH}} - Path to agent's history JSON file (for task recall) * {{TAB_NAME}} - Custom tab name (alias: SESSION_NAME) * {{TOOL_TYPE}} - Agent type (claude-code, aider, etc.) * @@ -65,12 +66,15 @@ export interface TemplateContext { // Auto Run document context documentName?: string; documentPath?: string; + // History file path for task recall + historyFilePath?: string; } // List of all available template variables for documentation (alphabetically sorted) // Variables marked as autoRunOnly are only shown in Auto Run contexts, not in AI Commands settings export const TEMPLATE_VARIABLES = [ { variable: '{{AGENT_GROUP}}', description: 'Agent group name' }, + { variable: '{{AGENT_HISTORY_PATH}}', description: 'History file path (task recall)' }, { variable: '{{AGENT_NAME}}', description: 'Agent name' }, { variable: '{{AGENT_PATH}}', description: 'Agent home directory path' }, { variable: '{{AGENT_SESSION_ID}}', description: 'Agent session ID' }, @@ -106,7 +110,7 @@ export function substituteTemplateVariables( template: string, context: TemplateContext ): string { - const { session, gitBranch, groupName, autoRunFolder, loopNumber, documentName, documentPath } = context; + const { session, gitBranch, groupName, autoRunFolder, loopNumber, documentName, documentPath, historyFilePath } = context; const now = new Date(); // Build replacements map @@ -116,6 +120,7 @@ export function substituteTemplateVariables( 'AGENT_PATH': session.fullPath || session.projectRoot || session.cwd, 'AGENT_GROUP': groupName || '', 'AGENT_SESSION_ID': session.agentSessionId || '', + 'AGENT_HISTORY_PATH': historyFilePath || '', 'TAB_NAME': session.name, 'TOOL_TYPE': session.toolType,