- Streamline Key Concepts table with clearer terminology - Move Mermaid architecture diagram to ARCHITECTURE.md - Rename "Project" to "Agent" in diagram labels for consistency - Keep README focused on user-facing information Claude ID: 900964ee-78bd-47d3-af6a-f8ef6112e80c Maestro ID: 5a166b38-b7e9-47f0-a8ff-0113c65f2682
18 KiB
Architecture Guide
Deep technical documentation for Maestro's architecture and design patterns. For quick reference, see CLAUDE.md. For development setup, see CONTRIBUTING.md.
Table of Contents
- Dual-Process Architecture
- IPC Security Model
- Process Manager
- Layer Stack System
- Custom Hooks
- Services Layer
- Slash Commands System
- Theme System
- Settings Persistence
- Claude Sessions API
- Error Handling Patterns
Architecture
Maestro organizes work into Projects (workspaces), each with a CLI Terminal and multiple Agent Tabs. Each tab can be connected to an Agent Session - either newly created or resumed from the session pool.
graph LR
subgraph Maestro["Maestro App"]
subgraph ProjectA["Agent A (workspace)"]
TermA[CLI Terminal]
subgraph TabsA["Agent Tabs"]
Tab1A[Tab 1]
Tab2A[Tab 2]
end
end
subgraph ProjectB["Agent B (workspace)"]
TermB[CLI Terminal]
subgraph TabsB["Agent Tabs"]
Tab1B[Tab 1]
Tab2B[Tab 2]
end
end
end
subgraph SessionPool["Agent Session Pool"]
direction TB
S1["Session α"]
S2["Session β"]
S3["Session γ"]
S4["Session δ"]
S5["..."]
end
Tab1A -.->|"resume"| S1
Tab2A -.->|"resume"| S2
Tab1B -.->|"resume"| S3
Tab2B -.->|"new"| S4
style Maestro fill:#9b8cd6,stroke:#6b5b95
style ProjectA fill:#87ceeb,stroke:#4682b4
style ProjectB fill:#87ceeb,stroke:#4682b4
style TermA fill:#90ee90,stroke:#228b22
style TermB fill:#90ee90,stroke:#228b22
style TabsA fill:#ffe4a0,stroke:#daa520
style TabsB fill:#ffe4a0,stroke:#daa520
style SessionPool fill:#ffb6c1,stroke:#dc143c
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:
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
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()withshell: 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
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
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
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
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:
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,apiKeydefaultAgent,defaultShellfontFamily,fontSize,customFontsactiveThemeIdenterToSendAI,enterToSendTerminalleftSidebarWidth,rightPanelWidthmarkdownRawModeterminalWidthlogLevel,maxLogBuffermaxOutputLinesshortcuts
useSessionManager (src/renderer/hooks/useSessionManager.ts)
Manages sessions and groups with CRUD operations.
Key methods:
createNewSession(agentId, workingDir, name)- Creates new session with dual processesdeleteSession(id, showConfirmation)- Delete with confirmationtoggleInputMode()- Switch between AI and terminal modeupdateScratchPad(content)- Update session scratchpadcreateNewGroup(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 apploadFileTree(dirPath, maxDepth?)- Load directory treetoggleFolder(path, activeSessionId, setSessions)- Toggle folder expansionexpandAllFolders()/collapseAllFolders()updateSessionWorkingDirectory()- Change session CWD
Services Layer
Services provide clean wrappers around IPC calls.
Git Service (src/renderer/services/git.ts)
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)
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
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:
{
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
interface Theme {
id: ThemeId;
name: string;
mode: 'light' | 'dark' | 'vibe';
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
accentForeground: string; // Text ON accent backgrounds (contrast)
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:
style={{ color: theme.colors.textMain }} // Correct
Use Tailwind for layout:
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 preferencesmaestro-sessions.json- Session persistencemaestro-groups.json- Session groupsmaestro-agent-configs.json- Per-agent configuration
Adding New Settings
- Add state in
useSettings.ts:
const [mySetting, setMySettingState] = useState<MyType>(defaultValue);
- Create wrapper function:
const setMySetting = (value: MyType) => {
setMySettingState(value);
window.maestro.settings.set('mySetting', value);
};
- Load in useEffect:
const saved = await window.maestro.settings.get('mySetting');
if (saved !== undefined) setMySettingState(saved);
- 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
// 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
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
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
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
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 |