Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
244f2e4
fix: restore Cmd+/- font size shortcuts lost when custom menu was added
pedramamini Mar 4, 2026
5272ae1
feat: add Create Worktree command to command palette with auto-focus
pedramamini Mar 4, 2026
f6e9ebb
fix: format symphony-registry.json and register font size shortcuts
pedramamini Mar 4, 2026
05a4bfb
fix: resolve duplicate entries in file tree by using tree-structured …
pedramamini Mar 5, 2026
38a741c
feat: add --read-only flag to maestro-cli send command
pedramamini Mar 5, 2026
05f3cf8
fix: allow session name pill to shrink so date doesn't collide with t…
pedramamini Mar 5, 2026
8eaed7e
feat: add unread agents filter toggle with Cmd+Shift+U shortcut
pedramamini Mar 5, 2026
ce5cf67
fix: use textMain for session names to prevent visual dimming
pedramamini Mar 6, 2026
8d2b003
fix: count only agents with entries in lookback window for Director's…
pedramamini Mar 6, 2026
926f978
fix: preserve draft input when replaying a previous message
pedramamini Mar 6, 2026
7409805
fix: match unread agent indicator dot position to tab unread pattern
pedramamini Mar 6, 2026
bca4bed
feat: show View history link on files tab during batch run
pedramamini Mar 6, 2026
4d0e212
fix: remove hardcoded max-width on header session name
pedramamini Mar 6, 2026
564d85f
fix: skip directory collision warning when agents are on different hosts
pedramamini Mar 6, 2026
e47cb59
fix: check sessionSshRemoteConfig as primary SSH remote ID source
pedramamini Mar 6, 2026
16322c8
fix: use dark text colors for context warning sash in light mode
pedramamini Mar 6, 2026
d507d10
fix: dropdown clipping on hamburger menu and live overlay, rename Rem…
pedramamini Mar 6, 2026
96190ec
fix: always show .maestro folder in file tree regardless of ignore pa…
pedramamini Mar 6, 2026
cecb0cb
fix: improve light theme contrast for syntax highlighting and colors
pedramamini Mar 7, 2026
734d773
fix: include busy agents in unread agents filter
pedramamini Mar 7, 2026
44be9a7
fix: suppress empty groups and New Group button in unread agents filter
pedramamini Mar 7, 2026
c6734e5
feat: add empty state for unread agents filter with centered Bot icon
pedramamini Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"symphony",
"git-worktrees",
"group-chat",
"remote-access",
"remote-control",
"ssh-remote-execution",
"configuration"
]
Expand Down
4 changes: 2 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Features
description: Explore Maestro's power features including Git Worktrees, Auto Run, Group Chat, and Remote Access.
description: Explore Maestro's power features including Git Worktrees, Auto Run, Group Chat, and Remote Control.
icon: sparkles
---

Expand All @@ -11,7 +11,7 @@ icon: sparkles
- 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more.
- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development.
- 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions.
- 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere.
- 🌐 **[Remote Control](./remote-control)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere.
- 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance.
- 💻 **[Command Line Interface](./cli)** - Full CLI (`maestro-cli`) for headless operation. List agents/groups, run playbooks from cron jobs or CI/CD pipelines, with human-readable or JSONL output for scripting.
- 🚀 **Multi-Agent Management** - Run unlimited agents in parallel. Each agent has its own workspace, conversation history, and isolated context.
Expand Down
6 changes: 3 additions & 3 deletions docs/remote-access.md → docs/remote-control.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: Remote Access
title: Remote Control
description: Control Maestro from your phone via the built-in web server and Cloudflare tunnels.
icon: wifi
---
Expand Down Expand Up @@ -45,13 +45,13 @@ The mobile web interface provides a comprehensive remote control experience:
The web interface uses your local IP address (e.g., `192.168.x.x`) for LAN accessibility. Both devices must be on the same network.
</Note>

## Remote Access (Outside Your Network)
## Remote Control (Outside Your Network)

To access Maestro from outside your local network (e.g., on mobile data or from another location):

1. Install cloudflared: `brew install cloudflared` (macOS) or [download for other platforms](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)
2. Enable the web interface (**OFFLINE** → **LIVE**)
3. Toggle **Remote Access** in the Live overlay panel
3. Toggle **Remote Control** in the Live overlay panel
4. A secure Cloudflare tunnel URL (e.g., `https://abc123.trycloudflare.com`) will be generated within ~30 seconds
5. Use the **Local/Remote** pill selector to switch between QR codes
6. The tunnel stays active as long as Maestro is running — no time limits, no Cloudflare account required
Expand Down
34 changes: 30 additions & 4 deletions src/__tests__/cli/commands/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ describe('send command', () => {
'claude-code',
'/path/to/project',
'Hello world',
undefined
undefined,
{ readOnlyMode: undefined }
);
expect(consoleSpy).toHaveBeenCalledTimes(1);

Expand Down Expand Up @@ -128,7 +129,8 @@ describe('send command', () => {
'claude-code',
'/path/to/project',
'Continue from before',
'session-xyz-789'
'session-xyz-789',
{ readOnlyMode: undefined }
);

const output = JSON.parse(consoleSpy.mock.calls[0][0]);
Expand All @@ -153,7 +155,8 @@ describe('send command', () => {
'claude-code',
'/custom/project/path',
'Do something',
undefined
undefined,
{ readOnlyMode: undefined }
);
});

Expand All @@ -173,7 +176,30 @@ describe('send command', () => {

expect(detectCodex).toHaveBeenCalled();
expect(detectClaude).not.toHaveBeenCalled();
expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined);
expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined, {
readOnlyMode: undefined,
});
});

it('should pass readOnlyMode when --read-only flag is set', async () => {
vi.mocked(resolveAgentId).mockReturnValue('agent-abc-123');
vi.mocked(getSessionById).mockReturnValue(mockAgent());
vi.mocked(detectClaude).mockResolvedValue({ available: true, path: '/usr/bin/claude' });
vi.mocked(spawnAgent).mockResolvedValue({
success: true,
response: 'Read-only response',
agentSessionId: 'session-ro',
});

await send('agent-abc', 'Analyze this code', { readOnly: true });

expect(spawnAgent).toHaveBeenCalledWith(
'claude-code',
'/path/to/project',
'Analyze this code',
undefined,
{ readOnlyMode: true }
);
});

it('should exit with error when agent ID is not found', async () => {
Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/cli/services/agent-spawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,42 @@ Some text with [x] in it that's not a checkbox
}
});

it('should include read-only args for Claude when readOnlyMode is true', async () => {
const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, {
readOnlyMode: true,
});

await new Promise((resolve) => setTimeout(resolve, 0));

const [, args] = mockSpawn.mock.calls[0];
// Should include Claude's read-only args from centralized definitions
expect(args).toContain('--permission-mode');
expect(args).toContain('plan');
// Should still have base args
expect(args).toContain('--print');
expect(args).toContain('--dangerously-skip-permissions');

mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n'));
mockChild.emit('close', 0);
await resultPromise;
});

it('should not include read-only args when readOnlyMode is false', async () => {
const resultPromise = spawnAgent('claude-code', '/project', 'prompt', undefined, {
readOnlyMode: false,
});

await new Promise((resolve) => setTimeout(resolve, 0));

const [, args] = mockSpawn.mock.calls[0];
expect(args).not.toContain('--permission-mode');
expect(args).not.toContain('plan');

mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n'));
mockChild.emit('close', 0);
await resultPromise;
});

it('should generate unique session-id for each spawn', async () => {
// First spawn
const promise1 = spawnAgent('claude-code', '/project', 'prompt1');
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/integration/AutoRunBatchProcessing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/integration/AutoRunRightPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/integration/AutoRunSessionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/main/ipc/handlers/director-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,37 @@ describe('director-notes IPC handlers', () => {
expect(result.stats.totalCount).toBe(3);
});

it('should only count agents with entries in lookback window for agentCount', async () => {
const now = Date.now();
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000;
const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000;

// 3 sessions on disk, but only 2 have entries within 7-day lookback
vi.mocked(mockHistoryManager.listSessionsWithHistory).mockReturnValue([
'session-1',
'session-2',
'session-3',
]);

vi.mocked(mockHistoryManager.getEntries)
.mockReturnValueOnce([
createMockEntry({ id: 'e1', timestamp: twoDaysAgo, agentSessionId: 'as-1' }),
])
.mockReturnValueOnce([
// session-2 only has old entries outside lookback
createMockEntry({ id: 'e2', timestamp: tenDaysAgo, agentSessionId: 'as-2' }),
])
.mockReturnValueOnce([
createMockEntry({ id: 'e3', timestamp: twoDaysAgo, agentSessionId: 'as-3' }),
]);

const handler = handlers.get('director-notes:getUnifiedHistory');
const result = await handler!({} as any, { lookbackDays: 7 });

expect(result.stats.agentCount).toBe(2); // Only 2 agents had entries in window
expect(result.entries).toHaveLength(2);
});

it('should filter by lookbackDays', async () => {
const now = Date.now();
const twoDaysAgo = now - 2 * 24 * 60 * 60 * 1000;
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/performance/AutoRunLargeDocument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/performance/AutoRunManyDocuments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/performance/AutoRunMemoryLeaks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/renderer/components/AutoRun.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock('react-syntax-highlighter', () => ({

vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

vi.mock('../../../renderer/components/AutoRunnerHelpModal', () => ({
Expand Down
41 changes: 41 additions & 0 deletions src/__tests__/renderer/components/ContextWarningSash.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -544,4 +544,45 @@ describe('ContextWarningSash', () => {
expect(container.firstChild).toBeNull();
});
});

describe('light mode contrast', () => {
const lightTheme: Theme = {
...theme,
id: 'light-test',
name: 'Light Test',
mode: 'light',
};

it('should use dark text colors in light mode for yellow warning', () => {
render(
<ContextWarningSash
theme={lightTheme}
contextUsage={65}
yellowThreshold={60}
redThreshold={80}
enabled={true}
onSummarizeClick={mockOnSummarizeClick}
/>
);
const warningText = screen.getByText(/reaching/);
// yellow-800 (#854d0e) for light mode instead of yellow-300
expect(warningText).toHaveStyle({ color: '#854d0e' });
});

it('should use dark text colors in light mode for red warning', () => {
render(
<ContextWarningSash
theme={lightTheme}
contextUsage={85}
yellowThreshold={60}
redThreshold={80}
enabled={true}
onSummarizeClick={mockOnSummarizeClick}
/>
);
const warningText = screen.getByText(/consider compacting/);
// red-800 (#991b1b) for light mode instead of red-300
expect(warningText).toHaveStyle({ color: '#991b1b' });
});
});
});
2 changes: 2 additions & 0 deletions src/__tests__/renderer/components/FilePreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ vi.mock('react-syntax-highlighter', () => ({
}));
vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

// Mock unist-util-visit
Expand Down Expand Up @@ -164,6 +165,7 @@ vi.mock('../../../shared/gitUtils', () => ({
}));

const mockTheme = {
mode: 'dark',
colors: {
bgMain: '#1a1a2e',
bgActivity: '#16213e',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ describe('HistoryEntryItem', () => {
expect(screen.getByText('ABC12345')).toBeInTheDocument();
});

it('session name pill is shrinkable to avoid date collision', () => {
const entry = createMockEntry({
agentSessionId: 'abc12345-def6-7890',
sessionName: 'A Very Long Session Name That Should Truncate',
});
render(
<HistoryEntryItem
entry={entry}
index={0}
isSelected={false}
theme={mockTheme}
onOpenDetailModal={vi.fn()}
/>
);
const sessionButton = screen.getByTitle('A Very Long Session Name That Should Truncate');
expect(sessionButton).toHaveClass('flex-shrink');
expect(sessionButton).not.toHaveClass('flex-shrink-0');
});

it('shows session name when both sessionName and agentSessionId are present', () => {
const entry = createMockEntry({
agentSessionId: 'abc12345-def6-7890',
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/renderer/components/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('react-syntax-highlighter', () => ({
}));
vi.mock('react-syntax-highlighter/dist/esm/styles/prism', () => ({
vscDarkPlus: {},
vs: {},
}));

// Mock lucide-react icons
Expand All @@ -22,6 +23,7 @@ vi.mock('lucide-react', () => ({

const mockTheme = {
id: 'test-theme',
mode: 'dark',
colors: {
bgMain: '#1a1a2e',
bgActivity: '#16213e',
Expand Down
Loading