mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
- Add claude:getGlobalStats IPC handler that calculates stats across all Claude projects (~/.claude/projects/) with streaming progress updates - Add onGlobalStatsUpdate listener for real-time stats progress in UI - Refactor AboutModal to fetch global stats on mount instead of receiving persisted stats prop, with loading state and animated cost counter - Fix busy indicator to only show when busySource matches current inputMode - Add isSystemCommand property to CustomAICommand for special handling - Add /synopsis as built-in system command (handled by slashCommands.ts) - Remove duplicate Built-in System Commands section from AICommandsPanel - Update CLAUDE.md and ARCHITECTURE.md to document new global stats API Claude ID: ac8e7811-8742-4991-b9ce-9c03629b8288 Maestro ID: 5a166b38-b7e9-47f0-a8ff-0113c65f2682
613 lines
17 KiB
Markdown
613 lines
17 KiB
Markdown
# Architecture Guide
|
|
|
|
Deep technical documentation for Maestro's architecture and design patterns. For quick reference, see [CLAUDE.md](CLAUDE.md). For development setup, see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
|
## Table of Contents
|
|
|
|
- [Dual-Process Architecture](#dual-process-architecture)
|
|
- [IPC Security Model](#ipc-security-model)
|
|
- [Process Manager](#process-manager)
|
|
- [Layer Stack System](#layer-stack-system)
|
|
- [Custom Hooks](#custom-hooks)
|
|
- [Services Layer](#services-layer)
|
|
- [Slash Commands System](#slash-commands-system)
|
|
- [Theme System](#theme-system)
|
|
- [Settings Persistence](#settings-persistence)
|
|
- [Claude Sessions API](#claude-sessions-api)
|
|
- [Error Handling Patterns](#error-handling-patterns)
|
|
|
|
---
|
|
|
|
## Dual-Process Architecture
|
|
|
|
Maestro uses Electron's main/renderer split with strict context isolation.
|
|
|
|
### Main Process (`src/main/`)
|
|
|
|
Node.js backend with full system access:
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `index.ts` | App entry, IPC handlers, window management |
|
|
| `process-manager.ts` | PTY and child process spawning |
|
|
| `web-server.ts` | Fastify HTTP/WebSocket server for mobile remote control |
|
|
| `agent-detector.ts` | Auto-detect CLI tools via PATH |
|
|
| `preload.ts` | Secure IPC bridge via contextBridge |
|
|
| `utils/execFile.ts` | Safe command execution utility |
|
|
| `utils/logger.ts` | System logging with levels |
|
|
| `utils/shellDetector.ts` | Detect available shells |
|
|
| `utils/terminalFilter.ts` | Strip terminal control sequences |
|
|
|
|
### Renderer Process (`src/renderer/`)
|
|
|
|
React frontend with no direct Node.js access:
|
|
|
|
| Directory | Purpose |
|
|
|-----------|---------|
|
|
| `components/` | React UI components |
|
|
| `hooks/` | Custom React hooks (useSettings, useSessionManager, useFileExplorer) |
|
|
| `services/` | IPC wrappers (git.ts, process.ts) |
|
|
| `contexts/` | React contexts (LayerStackContext) |
|
|
| `constants/` | Themes, shortcuts, modal priorities |
|
|
| `types/` | TypeScript definitions |
|
|
| `utils/` | Frontend utilities |
|
|
|
|
### Session Model
|
|
|
|
Each session runs **two processes simultaneously**:
|
|
|
|
```typescript
|
|
interface Session {
|
|
id: string; // Unique identifier
|
|
aiPid: number; // AI agent process (suffixed -ai)
|
|
terminalPid: number; // Terminal process (suffixed -terminal)
|
|
inputMode: 'ai' | 'terminal'; // Which process receives input
|
|
// ... other fields
|
|
}
|
|
```
|
|
|
|
This enables seamless switching between AI and terminal modes without process restarts.
|
|
|
|
---
|
|
|
|
## IPC Security Model
|
|
|
|
All renderer-to-main communication uses the preload script:
|
|
|
|
- **Context isolation**: Enabled (renderer has no Node.js access)
|
|
- **Node integration**: Disabled (no `require()` in renderer)
|
|
- **Preload script**: Exposes minimal API via `contextBridge.exposeInMainWorld('maestro', ...)`
|
|
|
|
### The `window.maestro` API
|
|
|
|
```typescript
|
|
window.maestro = {
|
|
settings: { get, set, getAll },
|
|
sessions: { getAll, setAll },
|
|
groups: { getAll, setAll },
|
|
process: { spawn, write, interrupt, kill, resize, runCommand, onData, onExit, onSessionId, onStderr, onCommandExit, onUsage },
|
|
git: { status, diff, isRepo, numstat },
|
|
fs: { readDir, readFile },
|
|
agents: { detect, get, getConfig, setConfig, getConfigValue, setConfigValue },
|
|
claude: { listSessions, readSessionMessages, searchSessions },
|
|
dialog: { selectFolder },
|
|
fonts: { detect },
|
|
shells: { detect },
|
|
shell: { openExternal },
|
|
devtools: { open, close, toggle },
|
|
logger: { log, getLogs, clearLogs, setLogLevel, getLogLevel, setMaxLogBuffer, getMaxLogBuffer },
|
|
webserver: { getUrl },
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Process Manager
|
|
|
|
The `ProcessManager` class (`src/main/process-manager.ts`) handles two process types:
|
|
|
|
### PTY Processes (via `node-pty`)
|
|
|
|
Used for terminal sessions with full shell emulation:
|
|
- `toolType: 'terminal'`
|
|
- Supports resize, ANSI escape codes, interactive shell
|
|
- Spawned with shell (zsh, bash, fish, etc.)
|
|
|
|
### Child Processes (via `child_process.spawn`)
|
|
|
|
Used for AI assistants:
|
|
- All non-terminal tool types
|
|
- Direct stdin/stdout/stderr capture
|
|
- **Security**: Uses `spawn()` with `shell: false`
|
|
|
|
### Batch Mode (Claude Code)
|
|
|
|
Claude Code runs in batch mode with `--print --output-format json`:
|
|
- Prompt passed as CLI argument
|
|
- Process exits after response
|
|
- JSON response parsed for result and usage stats
|
|
|
|
### Stream-JSON Mode (with images)
|
|
|
|
When images are attached:
|
|
- Uses `--input-format stream-json --output-format stream-json`
|
|
- Message sent via stdin as JSONL
|
|
- Supports multimodal input
|
|
|
|
### Process Events
|
|
|
|
```typescript
|
|
processManager.on('data', (sessionId, data) => { ... });
|
|
processManager.on('exit', (sessionId, code) => { ... });
|
|
processManager.on('usage', (sessionId, usageStats) => { ... });
|
|
processManager.on('session-id', (sessionId, claudeSessionId) => { ... });
|
|
processManager.on('stderr', (sessionId, data) => { ... });
|
|
processManager.on('command-exit', (sessionId, code) => { ... });
|
|
```
|
|
|
|
---
|
|
|
|
## Layer Stack System
|
|
|
|
Centralized modal/overlay management with predictable Escape key handling.
|
|
|
|
### Problem Solved
|
|
|
|
- Previously had 9+ scattered Escape handlers
|
|
- Brittle modal detection with massive boolean checks
|
|
- Inconsistent focus management
|
|
|
|
### Architecture
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `hooks/useLayerStack.ts` | Core layer management hook |
|
|
| `contexts/LayerStackContext.tsx` | Global Escape handler (capture phase) |
|
|
| `constants/modalPriorities.ts` | Priority values for all modals |
|
|
| `types/layer.ts` | Layer type definitions |
|
|
|
|
### Modal Priority Hierarchy
|
|
|
|
```typescript
|
|
const MODAL_PRIORITIES = {
|
|
CONFIRM: 1000, // Highest - confirmation dialogs
|
|
RENAME_INSTANCE: 900,
|
|
RENAME_GROUP: 850,
|
|
CREATE_GROUP: 800,
|
|
NEW_INSTANCE: 750,
|
|
QUICK_ACTION: 700, // Command palette (Cmd+K)
|
|
AGENT_SESSIONS: 680,
|
|
SHORTCUTS_HELP: 650,
|
|
ABOUT: 600,
|
|
PROCESS_MONITOR: 550,
|
|
LOG_VIEWER: 500,
|
|
SETTINGS: 450,
|
|
GIT_DIFF: 200,
|
|
LIGHTBOX: 150,
|
|
FILE_PREVIEW: 100,
|
|
SLASH_AUTOCOMPLETE: 50,
|
|
FILE_TREE_FILTER: 30, // Lowest
|
|
};
|
|
```
|
|
|
|
### Registering a Modal
|
|
|
|
```typescript
|
|
import { useLayerStack } from '../contexts/LayerStackContext';
|
|
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
|
|
|
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
|
const layerIdRef = useRef<string>();
|
|
|
|
// Use ref to avoid re-registration when callback identity changes
|
|
const onCloseRef = useRef(onClose);
|
|
onCloseRef.current = onClose;
|
|
|
|
useEffect(() => {
|
|
if (modalOpen) {
|
|
const id = registerLayer({
|
|
type: 'modal',
|
|
priority: MODAL_PRIORITIES.YOUR_MODAL,
|
|
blocksLowerLayers: true,
|
|
capturesFocus: true,
|
|
focusTrap: 'strict', // 'strict' | 'lenient' | 'none'
|
|
ariaLabel: 'Your Modal Name',
|
|
onEscape: () => onCloseRef.current(),
|
|
});
|
|
layerIdRef.current = id;
|
|
return () => unregisterLayer(id);
|
|
}
|
|
}, [modalOpen, registerLayer, unregisterLayer]); // onClose NOT in deps
|
|
```
|
|
|
|
### Layer Types
|
|
|
|
```typescript
|
|
type ModalLayer = {
|
|
type: 'modal';
|
|
priority: number;
|
|
blocksLowerLayers: boolean;
|
|
capturesFocus: boolean;
|
|
focusTrap: 'strict' | 'lenient' | 'none';
|
|
ariaLabel?: string;
|
|
onEscape: () => void;
|
|
onBeforeClose?: () => Promise<boolean>;
|
|
isDirty?: boolean;
|
|
parentModalId?: string;
|
|
};
|
|
|
|
type OverlayLayer = {
|
|
type: 'overlay';
|
|
priority: number;
|
|
blocksLowerLayers: boolean;
|
|
capturesFocus: boolean;
|
|
focusTrap: 'strict' | 'lenient' | 'none';
|
|
ariaLabel?: string;
|
|
onEscape: () => void;
|
|
allowClickOutside: boolean;
|
|
};
|
|
```
|
|
|
|
### Internal Search Layers
|
|
|
|
Components like FilePreview handle internal search in their onEscape:
|
|
|
|
```typescript
|
|
onEscape: () => {
|
|
if (searchOpen) {
|
|
setSearchOpen(false); // First Escape closes search
|
|
} else {
|
|
closePreview(); // Second Escape closes preview
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Custom Hooks
|
|
|
|
### useSettings (`src/renderer/hooks/useSettings.ts`)
|
|
|
|
Manages all application settings with automatic persistence.
|
|
|
|
**What it manages:**
|
|
- LLM settings (provider, model, API key)
|
|
- Agent settings (default agent)
|
|
- Shell settings (default shell)
|
|
- Font settings (family, size, custom fonts)
|
|
- UI settings (theme, enter-to-send modes, panel widths, markdown mode)
|
|
- Terminal settings (width)
|
|
- Logging settings (level, buffer size)
|
|
- Output settings (max lines)
|
|
- Keyboard shortcuts
|
|
|
|
**Current Persistent Settings:**
|
|
- `llmProvider`, `modelSlug`, `apiKey`
|
|
- `defaultAgent`, `defaultShell`
|
|
- `fontFamily`, `fontSize`, `customFonts`
|
|
- `activeThemeId`
|
|
- `enterToSendAI`, `enterToSendTerminal`
|
|
- `leftSidebarWidth`, `rightPanelWidth`
|
|
- `markdownRawMode`
|
|
- `terminalWidth`
|
|
- `logLevel`, `maxLogBuffer`
|
|
- `maxOutputLines`
|
|
- `shortcuts`
|
|
|
|
### useSessionManager (`src/renderer/hooks/useSessionManager.ts`)
|
|
|
|
Manages sessions and groups with CRUD operations.
|
|
|
|
**Key methods:**
|
|
- `createNewSession(agentId, workingDir, name)` - Creates new session with dual processes
|
|
- `deleteSession(id, showConfirmation)` - Delete with confirmation
|
|
- `toggleInputMode()` - Switch between AI and terminal mode
|
|
- `updateScratchPad(content)` - Update session scratchpad
|
|
- `createNewGroup(name, emoji, moveSession, activeSessionId)`
|
|
- Drag and drop handlers
|
|
|
|
### useFileExplorer (`src/renderer/hooks/useFileExplorer.ts`)
|
|
|
|
Manages file tree state and navigation.
|
|
|
|
**Key methods:**
|
|
- `handleFileClick(node, path, activeSession)` - Open file or external app
|
|
- `loadFileTree(dirPath, maxDepth?)` - Load directory tree
|
|
- `toggleFolder(path, activeSessionId, setSessions)` - Toggle folder expansion
|
|
- `expandAllFolders()` / `collapseAllFolders()`
|
|
- `updateSessionWorkingDirectory()` - Change session CWD
|
|
|
|
---
|
|
|
|
## Services Layer
|
|
|
|
Services provide clean wrappers around IPC calls.
|
|
|
|
### Git Service (`src/renderer/services/git.ts`)
|
|
|
|
```typescript
|
|
import { gitService } from '../services/git';
|
|
|
|
const isRepo = await gitService.isRepo(cwd);
|
|
const status = await gitService.getStatus(cwd);
|
|
// Returns: { files: [{ path: string, status: string }] }
|
|
|
|
const diff = await gitService.getDiff(cwd, ['file1.ts']);
|
|
// Returns: { diff: string }
|
|
|
|
const numstat = await gitService.getNumstat(cwd);
|
|
// Returns: { files: [{ path, additions, deletions }] }
|
|
```
|
|
|
|
### Process Service (`src/renderer/services/process.ts`)
|
|
|
|
```typescript
|
|
import { processService } from '../services/process';
|
|
|
|
await processService.spawn(sessionId, config);
|
|
await processService.write(sessionId, 'input\n');
|
|
await processService.interrupt(sessionId); // SIGINT/Ctrl+C
|
|
await processService.kill(sessionId);
|
|
await processService.resize(sessionId, cols, rows);
|
|
|
|
const unsubscribe = processService.onData((sessionId, data) => { ... });
|
|
```
|
|
|
|
---
|
|
|
|
## Slash Commands System
|
|
|
|
Extensible command system defined in `src/renderer/slashCommands.ts`.
|
|
|
|
### Interface
|
|
|
|
```typescript
|
|
interface SlashCommand {
|
|
command: string; // e.g., "/clear"
|
|
description: string;
|
|
terminalOnly?: boolean; // Only show in terminal mode
|
|
execute: (context: SlashCommandContext) => void;
|
|
}
|
|
|
|
interface SlashCommandContext {
|
|
activeSessionId: string;
|
|
sessions: any[];
|
|
setSessions: (sessions) => void;
|
|
currentMode: 'ai' | 'terminal';
|
|
setRightPanelOpen?: (open: boolean) => void;
|
|
setActiveRightTab?: (tab: string) => void;
|
|
setActiveFocus?: (focus: 'sidebar' | 'main' | 'right') => void;
|
|
setSelectedFileIndex?: (index: number) => void;
|
|
fileTreeRef?: React.RefObject<HTMLDivElement>;
|
|
}
|
|
```
|
|
|
|
### Adding Commands
|
|
|
|
Add to `slashCommands` array:
|
|
|
|
```typescript
|
|
{
|
|
command: '/mycommand',
|
|
description: 'Does something useful',
|
|
terminalOnly: false, // Optional: restrict to terminal mode
|
|
execute: (context) => {
|
|
const { activeSessionId, setSessions, currentMode } = context;
|
|
// Your logic here
|
|
}
|
|
}
|
|
```
|
|
|
|
### Current Commands
|
|
|
|
| Command | Description | Mode |
|
|
|---------|-------------|------|
|
|
| `/clear` | Clear output history for current mode | Both |
|
|
| `/jump` | Jump to CWD in file tree | Terminal only |
|
|
|
|
---
|
|
|
|
## Theme System
|
|
|
|
Themes defined in `src/renderer/constants/themes.ts`.
|
|
|
|
### Theme Structure
|
|
|
|
```typescript
|
|
interface Theme {
|
|
id: ThemeId;
|
|
name: string;
|
|
mode: 'light' | 'dark';
|
|
colors: {
|
|
bgMain: string; // Main content background
|
|
bgSidebar: string; // Sidebar background
|
|
bgActivity: string; // Accent background
|
|
border: string; // Border colors
|
|
textMain: string; // Primary text
|
|
textDim: string; // Secondary text
|
|
accent: string; // Accent color
|
|
accentDim: string; // Dimmed accent
|
|
accentText: string; // Accent text color
|
|
success: string; // Success state (green)
|
|
warning: string; // Warning state (yellow)
|
|
error: string; // Error state (red)
|
|
};
|
|
}
|
|
```
|
|
|
|
### Available Themes
|
|
|
|
**Dark themes:** Dracula, Monokai, Nord, Tokyo Night, Catppuccin Mocha, Gruvbox Dark
|
|
|
|
**Light themes:** GitHub, Solarized, One Light, Gruvbox Light, Catppuccin Latte, Ayu Light
|
|
|
|
### Usage
|
|
|
|
Use inline styles for theme colors:
|
|
```typescript
|
|
style={{ color: theme.colors.textMain }} // Correct
|
|
```
|
|
|
|
Use Tailwind for layout:
|
|
```typescript
|
|
className="flex items-center gap-2" // Correct
|
|
```
|
|
|
|
---
|
|
|
|
## Settings Persistence
|
|
|
|
Settings stored via `electron-store`:
|
|
|
|
**Locations:**
|
|
- **macOS**: `~/Library/Application Support/maestro/`
|
|
- **Windows**: `%APPDATA%/maestro/`
|
|
- **Linux**: `~/.config/maestro/`
|
|
|
|
**Files:**
|
|
- `maestro-settings.json` - User preferences
|
|
- `maestro-sessions.json` - Session persistence
|
|
- `maestro-groups.json` - Session groups
|
|
- `maestro-agent-configs.json` - Per-agent configuration
|
|
|
|
### Adding New Settings
|
|
|
|
1. Add state in `useSettings.ts`:
|
|
```typescript
|
|
const [mySetting, setMySettingState] = useState<MyType>(defaultValue);
|
|
```
|
|
|
|
2. Create wrapper function:
|
|
```typescript
|
|
const setMySetting = (value: MyType) => {
|
|
setMySettingState(value);
|
|
window.maestro.settings.set('mySetting', value);
|
|
};
|
|
```
|
|
|
|
3. Load in useEffect:
|
|
```typescript
|
|
const saved = await window.maestro.settings.get('mySetting');
|
|
if (saved !== undefined) setMySettingState(saved);
|
|
```
|
|
|
|
4. Add to return object and export.
|
|
|
|
---
|
|
|
|
## Claude Sessions API
|
|
|
|
Browse and resume Claude Code sessions from `~/.claude/projects/`.
|
|
|
|
### Path Encoding
|
|
|
|
Claude Code encodes project paths by replacing `/` with `-`:
|
|
- `/Users/pedram/Projects/Maestro` → `-Users-pedram-Projects-Maestro`
|
|
|
|
### IPC Handlers
|
|
|
|
```typescript
|
|
// List sessions for a project
|
|
const sessions = await window.maestro.claude.listSessions(projectPath);
|
|
// Returns: [{ sessionId, projectPath, timestamp, modifiedAt, firstMessage, messageCount, sizeBytes }]
|
|
|
|
// Read messages with pagination
|
|
const { messages, total, hasMore } = await window.maestro.claude.readSessionMessages(
|
|
projectPath,
|
|
sessionId,
|
|
{ offset: 0, limit: 20 }
|
|
);
|
|
|
|
// Search sessions
|
|
const results = await window.maestro.claude.searchSessions(
|
|
projectPath,
|
|
'query',
|
|
'all' // 'title' | 'user' | 'assistant' | 'all'
|
|
);
|
|
|
|
// Get global stats across all Claude projects (with streaming updates)
|
|
const stats = await window.maestro.claude.getGlobalStats();
|
|
// Returns: { totalSessions, totalMessages, totalInputTokens, totalOutputTokens,
|
|
// totalCacheReadTokens, totalCacheCreationTokens, totalCostUsd, totalSizeBytes }
|
|
|
|
// Subscribe to streaming updates during stats calculation
|
|
const unsubscribe = window.maestro.claude.onGlobalStatsUpdate((stats) => {
|
|
console.log(`Progress: ${stats.totalSessions} sessions, $${stats.totalCostUsd.toFixed(2)}`);
|
|
if (stats.isComplete) console.log('Stats calculation complete');
|
|
});
|
|
// Call unsubscribe() to stop listening
|
|
```
|
|
|
|
### UI Access
|
|
|
|
- Shortcut: `Cmd+Shift+L`
|
|
- Quick Actions: `Cmd+K` → "View Agent Sessions"
|
|
- Button in main panel header
|
|
|
|
---
|
|
|
|
## Error Handling Patterns
|
|
|
|
### IPC Handlers (Main Process)
|
|
|
|
**Pattern 1: Throw for critical failures**
|
|
```typescript
|
|
ipcMain.handle('process:spawn', async (_, config) => {
|
|
if (!processManager) throw new Error('Process manager not initialized');
|
|
return processManager.spawn(config);
|
|
});
|
|
```
|
|
|
|
**Pattern 2: Try-catch with boolean return**
|
|
```typescript
|
|
ipcMain.handle('git:isRepo', async (_, cwd) => {
|
|
try {
|
|
const result = await execFileNoThrow('git', ['rev-parse', '--is-inside-work-tree'], cwd);
|
|
return result.exitCode === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
```
|
|
|
|
### Services (Renderer)
|
|
|
|
**Pattern: Never throw, return safe defaults**
|
|
```typescript
|
|
export const gitService = {
|
|
async isRepo(cwd: string): Promise<boolean> {
|
|
try {
|
|
return await window.maestro.git.isRepo(cwd);
|
|
} catch (error) {
|
|
console.error('Git isRepo error:', error);
|
|
return false;
|
|
}
|
|
},
|
|
};
|
|
```
|
|
|
|
### React Components
|
|
|
|
**Pattern: Try-catch with user-friendly errors**
|
|
```typescript
|
|
const handleFileLoad = async (path: string) => {
|
|
try {
|
|
const content = await window.maestro.fs.readFile(path);
|
|
setFileContent(content);
|
|
} catch (error) {
|
|
console.error('Failed to load file:', error);
|
|
setError('Failed to load file');
|
|
}
|
|
};
|
|
```
|
|
|
|
### Summary
|
|
|
|
| Layer | Pattern |
|
|
|-------|---------|
|
|
| IPC Handlers | Throw critical, catch optional |
|
|
| Services | Never throw, safe defaults |
|
|
| ProcessManager | Throw spawn failures, emit runtime events |
|
|
| Components | Try-catch async, show UI errors |
|
|
| Hooks | Internal catch, expose error state |
|