## CHANGES

- Persist Claude session context-usage percentage, surviving resumes and restarts 🧠
- New IPC/API: `claude:updateSessionContextUsage` to store live context stats 🔌
- Fix resumed sessions falsely showing 100% context from lifetime tokens 🧯
- Usage stats now preserve cost only; tokens intentionally zeroed 📉
- Resume now always fetches session origins to restore context usage 🧭
- Reconstruct context percent on resume by synthesizing equivalent input tokens 🧮
- Add `{{AGENT_HISTORY_PATH}}` template variable for prompts and commands 🧾
- System prompt gains Task Recall guidance using the history JSON file 🗂️
- Input processing now resolves history file path during prompt substitution 🧬
- Add Electron DevTools trace-export workarounds in performance docs 🛠️
This commit is contained in:
Pedram Amini
2026-01-14 17:07:45 -06:00
parent 753d5db906
commit 0cee135132
18 changed files with 202 additions and 27 deletions

View File

@@ -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.

View File

@@ -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',
];

View File

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

View File

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

View File

@@ -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)', () => {

View File

@@ -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;
}
/**

View File

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

View File

@@ -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) => {

View File

@@ -87,6 +87,7 @@ interface ClaudeSessionOriginInfo {
origin: ClaudeSessionOrigin;
sessionName?: string;
starred?: boolean;
contextUsage?: number;
}
interface ClaudeSessionOriginsData {
origins: Record<string, Record<string, ClaudeSessionOrigin | ClaudeSessionOriginInfo>>;

View File

@@ -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<boolean>;
updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => Promise<boolean>;
updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => Promise<boolean>;
getSessionOrigins: (projectPath: string) => Promise<Record<string, 'user' | 'auto' | { origin: 'user' | 'auto'; sessionName?: string; starred?: boolean }>>;
updateSessionContextUsage: (projectPath: string, agentSessionId: string, contextUsage: number) => Promise<boolean>;
getSessionOrigins: (projectPath: string) => Promise<Record<string, 'user' | 'auto' | { origin: 'user' | 'auto'; sessionName?: string; starred?: boolean; contextUsage?: number }>>;
deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>;
};
agentSessions: {

View File

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

View File

@@ -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

View File

@@ -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,

View File

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

View File

@@ -810,7 +810,8 @@ interface MaestroAPI {
registerSessionOrigin: (projectPath: string, agentSessionId: string, origin: 'user' | 'auto', sessionName?: string) => Promise<boolean>;
updateSessionName: (projectPath: string, agentSessionId: string, sessionName: string) => Promise<boolean>;
updateSessionStarred: (projectPath: string, agentSessionId: string, starred: boolean) => Promise<boolean>;
getSessionOrigins: (projectPath: string) => Promise<Record<string, 'user' | 'auto' | { origin: 'user' | 'auto'; sessionName?: string; starred?: boolean }>>;
updateSessionContextUsage: (projectPath: string, agentSessionId: string, contextUsage: number) => Promise<boolean>;
getSessionOrigins: (projectPath: string) => Promise<Record<string, 'user' | 'auto' | { origin: 'user' | 'auto'; sessionName?: string; starred?: boolean; contextUsage?: number }>>;
getAllNamedSessions: () => Promise<Array<{
agentId: string;
agentSessionId: string;

View File

@@ -196,16 +196,16 @@ export function useAgentSessionManagement(
}));
}
// Look up starred status and session name from stores if not provided
// Look up starred status, session name, and context usage from stores if not provided
let isStarred = starred ?? false;
let name = sessionName ?? null;
let storedContextUsage: number | undefined;
let finalUsageStats = usageStats;
const shouldLookupOrigins = activeSession.toolType === 'claude-code'
&& (starred === undefined || sessionName === undefined);
if (shouldLookupOrigins) {
// Always look up origins for Claude sessions to get contextUsage (and name/starred if not provided)
if (activeSession.toolType === 'claude-code') {
try {
// Look up session metadata from session origins (name and starred)
// Look up session metadata from session origins (name, starred, contextUsage)
// Note: getSessionOrigins is still Claude-specific until we add generic origin tracking
// Use projectRoot (not cwd) for consistent session storage access
const origins = await window.maestro.claude.getSessionOrigins(activeSession.projectRoot);
@@ -217,12 +217,31 @@ export function useAgentSessionManagement(
if (starred === undefined && originData.starred !== undefined) {
isStarred = originData.starred;
}
if (originData.contextUsage !== undefined) {
storedContextUsage = originData.contextUsage;
}
}
} catch (error) {
console.warn('[handleResumeSession] Failed to lookup starred/named status:', error);
console.warn('[handleResumeSession] Failed to lookup session metadata:', error);
}
}
// If we have stored contextUsage, set token values to reproduce that percentage
// The context calculation is: (inputTokens + cacheRead + cacheCreation) / contextWindow * 100
// So we set inputTokens = contextUsage * contextWindow / 100 to get the correct percentage
if (storedContextUsage !== undefined && storedContextUsage > 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
});

View File

@@ -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

View File

@@ -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,