Core implementation patterns for the Maestro codebase. For the main guide, see [[CLAUDE.md]].
Each agent runs two processes simultaneously:
- AI agent process (Claude Code, etc.) - spawned with
-aisuffix - Terminal process (PTY shell) - spawned with
-terminalsuffix
// Agent stores both PIDs (code interface: Session object)
session.aiPid; // AI agent process
session.terminalPid; // Terminal processAlways use execFileNoThrow for external commands:
import { execFileNoThrow } from './utils/execFile';
const result = await execFileNoThrow('git', ['status'], cwd);
// Returns: { stdout, stderr, exitCode } - never throwsNever use shell-based command execution - it creates injection vulnerabilities. The execFileNoThrow utility is the safe alternative.
Add new settings in useSettings.ts:
// 1. Add state with default value
const [mySetting, setMySettingState] = useState(defaultValue);
// 2. Add wrapper that persists
const setMySetting = (value) => {
setMySettingState(value);
window.maestro.settings.set('mySetting', value);
};
// 3. Load from batch response in useEffect (settings use batch loading)
// In the loadSettings useEffect, extract from allSettings object:
const allSettings = await window.maestro.settings.getAll();
const savedMySetting = allSettings['mySetting'];
if (savedMySetting !== undefined) setMySettingState(savedMySetting);- Create component in
src/renderer/components/ - Add priority in
src/renderer/constants/modalPriorities.ts - Register with layer stack:
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
const { registerLayer, unregisterLayer } = useLayerStack();
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (isOpen) {
const id = registerLayer({
type: 'modal',
priority: MODAL_PRIORITIES.YOUR_MODAL,
onEscape: () => onCloseRef.current(),
});
return () => unregisterLayer(id);
}
}, [isOpen, registerLayer, unregisterLayer]);Themes have 13 required colors. Use inline styles for theme colors:
style={{ color: theme.colors.textMain }} // Correct
className="text-gray-500" // Wrong for themed textAgents support multiple AI conversation tabs and file preview tabs in a unified tab bar.
Every tab in aiTabs or filePreviewTabs MUST have a corresponding entry in unifiedTabOrder. The TabBar renders from unifiedTabOrder — tabs missing from this array are invisible even if their content renders.
// Session tab state (three arrays that MUST stay in sync)
session.aiTabs: AITab[] // AI conversation tab data
session.filePreviewTabs: FilePreviewTab[] // File preview tab data
session.unifiedTabOrder: UnifiedTabRef[] // Visual order — TabBar source of truth
session.activeTabId: string // Active AI tab
session.activeFileTabId: string | null // Active file tab (null if AI tab active)Always update both the tab array AND unifiedTabOrder:
// CORRECT — tab appears in TabBar
return {
...s,
aiTabs: [...s.aiTabs, newTab],
activeTabId: newTabId,
unifiedTabOrder: [...s.unifiedTabOrder, { type: 'ai', id: newTabId }],
};
// WRONG — tab content renders but no tab visible
return {
...s,
aiTabs: [...s.aiTabs, newTab],
activeTabId: newTabId,
// unifiedTabOrder not updated — ghost tab!
};Use ensureInUnifiedTabOrder() to repair orphaned tabs defensively:
import { ensureInUnifiedTabOrder } from '../utils/tabHelpers';
return {
...s,
activeFileTabId: existingTab.id,
unifiedTabOrder: ensureInUnifiedTabOrder(s.unifiedTabOrder, 'file', existingTab.id),
};buildUnifiedTabs(session)— Builds the unified tab list from session data. FollowsunifiedTabOrderthen appends orphaned tabs as a safety net. Single source of truth used by bothuseTabHandlers.tsandtabStore.ts.ensureInUnifiedTabOrder(order, type, id)— Returns order unchanged if tab is present, appends it otherwise. Zero-cost no-op when no repair needed (returns same reference).
Messages are queued when the AI is busy:
// Queue items for sequential execution
interface QueuedItem {
type: 'message' | 'slashCommand';
content: string;
timestamp: number;
}
// Add to queue instead of sending directly when busy
session.executionQueue.push({ type: 'message', content, timestamp: Date.now() });File-based document automation system:
// Auto Run state on session
session.autoRunFolderPath?: string; // Document folder path
session.autoRunSelectedFile?: string; // Currently selected document
session.autoRunMode?: 'edit' | 'preview';
// API for Auto Run operations
window.maestro.autorun.listDocuments(folderPath);
window.maestro.autorun.readDocument(folderPath, filename);
window.maestro.autorun.saveDocument(folderPath, filename, content);Worktree Support: Auto Run can operate in a git worktree, allowing users to continue interactive editing in the main repo while Auto Run processes tasks in the background. When batchRunState.worktreeActive is true, read-only mode is disabled and a git branch icon appears in the UI. See useBatchProcessor.ts for worktree setup logic.
Playbook Assets: Playbooks can include non-markdown assets (config files, YAML, Dockerfiles, scripts) in an assets/ subfolder. When installing playbooks from the marketplace or importing from ZIP files, Maestro copies the entire folder structure including assets. See the Maestro-Playbooks repository for the convention documentation.
playbook-folder/
├── 01_TASK.md
├── 02_TASK.md
├── README.md
└── assets/
├── config.yaml
├── Dockerfile
└── setup.sh
Documents can reference assets using {{AUTORUN_FOLDER}}/assets/filename. The manifest lists assets explicitly:
{
"id": "example-playbook",
"documents": [...],
"assets": ["config.yaml", "Dockerfile", "setup.sh"]
}AI conversation tabs display a hover overlay menu after a 400ms delay when hovering over tabs with an established provider session. The overlay includes tab management and context operations:
Menu Structure:
// Tab operations (always shown)
- Copy Session ID (if provider session exists)
- Star/Unstar Session (if provider session exists)
- Rename Tab
- Mark as Unread
// Context management (shown when applicable)
- Context: Compact (if tab has 5+ messages)
- Context: Merge Into (if provider session exists)
- Context: Send to Agent (if provider session exists)
// Tab close actions (always shown)
- Close (disabled if only one tab)
- Close Others (disabled if only one tab)
- Close Tabs to the Left (disabled if first tab)
- Close Tabs to the Right (disabled if last tab)Implementation Pattern:
const [overlayOpen, setOverlayOpen] = useState(false);
const [overlayPosition, setOverlayPosition] = useState<{ top: number; left: number } | null>(null);
const handleMouseEnter = () => {
if (!tab.agentSessionId) return; // Only for tabs with provider sessions
hoverTimeoutRef.current = setTimeout(() => {
if (tabRef.current) {
const rect = tabRef.current.getBoundingClientRect();
setOverlayPosition({ top: rect.bottom + 4, left: rect.left });
}
setOverlayOpen(true);
}, 400);
};
// Render overlay via portal to escape stacking context
{overlayOpen && overlayPosition && createPortal(
<div style={{ top: overlayPosition.top, left: overlayPosition.left }}>
{/* Overlay menu items */}
</div>,
document.body
)}Key Features:
- Appears after 400ms hover delay (only for tabs with
agentSessionId) - Fixed positioning at tab bottom
- Mouse can move from tab to overlay without closing
- Disabled states with visual feedback (opacity-40, cursor-default)
- Theme-aware styling
- Dividers separate action groups
See src/renderer/components/TabBar.tsx (Tab component) for implementation details.
Agents can execute commands on remote hosts via SSH. Critical: There are two different SSH identifiers with different lifecycles:
// Set AFTER AI agent spawns (via onSshRemote callback)
session.sshRemoteId: string | undefined
// Set BEFORE spawn (user configuration)
session.sessionSshRemoteConfig: {
enabled: boolean;
remoteId: string | null; // The SSH config ID
workingDirOverride?: string;
}Common pitfall: sshRemoteId is only populated after the AI agent spawns. For terminal-only SSH agents (no AI process), it remains undefined. Always use both as fallback:
// WRONG - fails for terminal-only SSH agents
const sshId = session.sshRemoteId;
// CORRECT - works for all SSH agents
const sshId = session.sshRemoteId || session.sessionSshRemoteConfig?.remoteId;This applies to any operation that needs to run on the remote:
window.maestro.fs.readDir(path, sshId)gitService.isRepo(path, sshId)- Directory existence checks for
cdcommand tracking
Similarly for checking if an agent is remote:
// WRONG
const isRemote = !!session.sshRemoteId;
// CORRECT
const isRemote = !!session.sshRemoteId || !!session.sessionSshRemoteConfig?.enabled;When debugging visual issues (tooltips clipped, elements not visible, scroll behavior):
-
CSS First: Check parent container properties before code logic:
overflow: hiddenon ancestors (clipping issues)z-indexstacking context conflictspositionmismatches (fixed/absolute/relative)
-
Scroll Issues: Use
scrollIntoView({ block: 'nearest' })not centering -
Portal Escape: For overlays/tooltips that get clipped, use
createPortal(el, document.body)to escape stacking context -
Fixed Positioning: Elements with
position: fixedinside transformed parents won't position relative to viewport—check ancestor transforms
Common fixes:
// Tooltip/overlay escaping parent overflow
import { createPortal } from 'react-dom';
{isOpen && createPortal(<Overlay />, document.body)}
// Scroll element into view without centering
element.scrollIntoView({ block: 'nearest', behavior: 'smooth' });Optional features that not all users need should be gated behind Encore Features — disabled by default, completely invisible when off (no shortcuts, menus, or command palette entries).
Critical architecture detail: encoreFeatures state lives in App.tsx's useSettings() and is passed to SettingsModal as props (not consumed via SettingsModal's own useSettings()). This ensures toggles propagate immediately to App.tsx for gating.
When adding a new Encore Feature, gate all access points:
- Type flag — Add to
EncoreFeatureFlagsinsrc/renderer/types/index.ts - Default — Set to
falseinDEFAULT_ENCORE_FEATURESinuseSettings.ts - Toggle UI — Add section in SettingsModal's Encore tab (follow Director's Notes pattern)
- App.tsx — Gate modal rendering and callback props on
encoreFeatures.yourFeature - Keyboard shortcuts — Guard with
ctx.encoreFeatures?.yourFeatureinuseMainKeyboardHandler.ts - Hamburger menu — Make the setter optional, conditionally render the menu item in
SessionList.tsx - Command palette — Pass
undefinedfor the handler inQuickActionsModal.tsx(already conditionally renders based on handler existence)
Director's Notes is the first Encore Feature and serves as the canonical example:
- Flag:
encoreFeatures.directorNotesinEncoreFeatureFlags - App.tsx gating: Modal render wrapped in
{encoreFeatures.directorNotes && directorNotesOpen && (…)}, callback passed asencoreFeatures.directorNotes ? () => setDirectorNotesOpen(true) : undefined - Keyboard shortcut:
ctx.encoreFeatures?.directorNotesguard inuseMainKeyboardHandler.ts - Hamburger menu:
setDirectorNotesOpenmade optional inSessionList.tsx, button conditionally rendered with{setDirectorNotesOpen && (…)} - Command palette:
onOpenDirectorNotesalready conditionally renders inQuickActionsModal.tsx— passingundefinedfrom App.tsx is sufficient
When adding a new Encore Feature, mirror this pattern across all access points.
See CONTRIBUTING.md → Encore Features for the full contributor guide.