diff --git a/docs/deep-links.md b/docs/deep-links.md
new file mode 100644
index 000000000..a0e618dd7
--- /dev/null
+++ b/docs/deep-links.md
@@ -0,0 +1,96 @@
+---
+title: Deep Links
+description: Navigate to specific agents, tabs, and groups using maestro:// URLs from external apps, scripts, and OS notifications.
+icon: link
+---
+
+# Deep Links
+
+Maestro registers the `maestro://` URL protocol, enabling navigation to specific agents, tabs, and groups from external tools, scripts, shell commands, and OS notification clicks.
+
+## URL Format
+
+```
+maestro://[action]/[parameters]
+```
+
+### Available Actions
+
+| URL | Action |
+| ------------------------------------------- | ------------------------------------------ |
+| `maestro://focus` | Bring Maestro window to foreground |
+| `maestro://session/{sessionId}` | Navigate to an agent |
+| `maestro://session/{sessionId}/tab/{tabId}` | Navigate to a specific tab within an agent |
+| `maestro://group/{groupId}` | Expand a group and focus its first agent |
+
+IDs containing special characters (`/`, `?`, `#`, `%`, etc.) are automatically URI-encoded and decoded.
+
+## Usage
+
+### From Terminal
+
+```bash
+# macOS
+open "maestro://session/abc123"
+open "maestro://session/abc123/tab/def456"
+open "maestro://group/my-group-id"
+open "maestro://focus"
+
+# Linux
+xdg-open "maestro://session/abc123"
+
+# Windows
+start maestro://session/abc123
+```
+
+### OS Notification Clicks
+
+When Maestro is running in the background and an agent completes a task, the OS notification is automatically linked to the originating agent and tab. Clicking the notification brings Maestro to the foreground and navigates directly to that agent's tab.
+
+This works out of the box — no configuration needed. Ensure **OS Notifications** are enabled in Settings.
+
+### Template Variables
+
+Deep link URLs are available as template variables in system prompts, custom AI commands, and Auto Run documents:
+
+| Variable | Description | Example Value |
+| --------------------- | ---------------------------------------------- | ------------------------------------- |
+| `{{AGENT_DEEP_LINK}}` | Link to the current agent | `maestro://session/abc123` |
+| `{{TAB_DEEP_LINK}}` | Link to the current agent + active tab | `maestro://session/abc123/tab/def456` |
+| `{{GROUP_DEEP_LINK}}` | Link to the agent's group (empty if ungrouped) | `maestro://group/grp789` |
+
+These variables can be used in:
+
+- **System prompts** — give AI agents awareness of their own deep link for cross-referencing
+- **Custom AI commands** — include deep links in generated output
+- **Auto Run documents** — reference agents in batch automation workflows
+- **Custom notification commands** — include deep links in TTS or logging scripts
+
+### From Scripts and External Tools
+
+Any application can launch Maestro deep links by opening the URL. This enables integrations like:
+
+- CI/CD pipelines that open a specific agent after deployment
+- Shell scripts that navigate to a group after batch operations
+- Alfred/Raycast workflows for quick agent access
+- Bookmarks for frequently-used agents
+
+## Platform Behavior
+
+| Platform | Mechanism |
+| ----------------- | ----------------------------------------------------------------------------- |
+| **macOS** | `app.on('open-url')` delivers the URL to the running instance |
+| **Windows/Linux** | `app.on('second-instance')` delivers the URL via argv to the primary instance |
+| **Cold start** | URL is buffered and processed after the window is ready |
+
+Maestro uses a single-instance lock — opening a deep link when Maestro is already running delivers the URL to the existing instance rather than launching a new one.
+
+
+In development mode, protocol registration is skipped by default to avoid overriding the production app's handler. Set `REGISTER_DEEP_LINKS_IN_DEV=1` to enable it during development.
+
+
+## Related
+
+- [Configuration](./configuration) — OS notification settings
+- [General Usage](./general-usage) — Core UI and workflow patterns
+- [MCP Server](./mcp-server) — Connect AI applications to Maestro
diff --git a/docs/docs.json b/docs/docs.json
index 069f0c48b..e33c3057f 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -82,7 +82,7 @@
},
{
"group": "Integrations",
- "pages": ["mcp-server"],
+ "pages": ["mcp-server", "deep-links"],
"icon": "plug"
},
{
diff --git a/package.json b/package.json
index e4fc247c6..5c01dafd1 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,12 @@
"npmRebuild": false,
"appId": "com.maestro.app",
"productName": "Maestro",
+ "protocols": [
+ {
+ "name": "Maestro",
+ "schemes": ["maestro"]
+ }
+ ],
"publish": {
"provider": "github",
"owner": "RunMaestro",
diff --git a/src/__tests__/main/deep-links.test.ts b/src/__tests__/main/deep-links.test.ts
new file mode 100644
index 000000000..25b373897
--- /dev/null
+++ b/src/__tests__/main/deep-links.test.ts
@@ -0,0 +1,141 @@
+/**
+ * Tests for deep link URL parsing
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock electron before importing the module under test
+vi.mock('electron', () => ({
+ app: {
+ isPackaged: false,
+ setAsDefaultProtocolClient: vi.fn(),
+ requestSingleInstanceLock: vi.fn().mockReturnValue(true),
+ on: vi.fn(),
+ quit: vi.fn(),
+ },
+ BrowserWindow: {
+ getAllWindows: vi.fn().mockReturnValue([]),
+ },
+}));
+
+vi.mock('../../main/utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ debug: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('../../main/utils/safe-send', () => ({
+ isWebContentsAvailable: vi.fn().mockReturnValue(true),
+}));
+
+import { parseDeepLink } from '../../main/deep-links';
+
+describe('parseDeepLink', () => {
+ describe('focus action', () => {
+ it('should parse maestro://focus', () => {
+ expect(parseDeepLink('maestro://focus')).toEqual({ action: 'focus' });
+ });
+
+ it('should parse empty path as focus', () => {
+ expect(parseDeepLink('maestro://')).toEqual({ action: 'focus' });
+ });
+
+ it('should parse protocol-only as focus', () => {
+ expect(parseDeepLink('maestro:')).toEqual({ action: 'focus' });
+ });
+ });
+
+ describe('session action', () => {
+ it('should parse session URL', () => {
+ expect(parseDeepLink('maestro://session/abc123')).toEqual({
+ action: 'session',
+ sessionId: 'abc123',
+ });
+ });
+
+ it('should parse session URL with tab', () => {
+ expect(parseDeepLink('maestro://session/abc123/tab/tab456')).toEqual({
+ action: 'session',
+ sessionId: 'abc123',
+ tabId: 'tab456',
+ });
+ });
+
+ it('should decode URI-encoded session IDs', () => {
+ expect(parseDeepLink('maestro://session/session%20with%20space')).toEqual({
+ action: 'session',
+ sessionId: 'session with space',
+ });
+ });
+
+ it('should decode URI-encoded tab IDs', () => {
+ expect(parseDeepLink('maestro://session/abc/tab/tab%2Fslash')).toEqual({
+ action: 'session',
+ sessionId: 'abc',
+ tabId: 'tab/slash',
+ });
+ });
+
+ it('should return null for session without ID', () => {
+ expect(parseDeepLink('maestro://session')).toBeNull();
+ expect(parseDeepLink('maestro://session/')).toBeNull();
+ });
+
+ it('should ignore extra path segments after tab ID', () => {
+ const result = parseDeepLink('maestro://session/abc/tab/tab1/extra/stuff');
+ expect(result).toEqual({
+ action: 'session',
+ sessionId: 'abc',
+ tabId: 'tab1',
+ });
+ });
+ });
+
+ describe('group action', () => {
+ it('should parse group URL', () => {
+ expect(parseDeepLink('maestro://group/grp789')).toEqual({
+ action: 'group',
+ groupId: 'grp789',
+ });
+ });
+
+ it('should decode URI-encoded group IDs', () => {
+ expect(parseDeepLink('maestro://group/group%20name')).toEqual({
+ action: 'group',
+ groupId: 'group name',
+ });
+ });
+
+ it('should return null for group without ID', () => {
+ expect(parseDeepLink('maestro://group')).toBeNull();
+ expect(parseDeepLink('maestro://group/')).toBeNull();
+ });
+ });
+
+ describe('Windows compatibility', () => {
+ it('should handle Windows maestro: prefix (no double slash)', () => {
+ expect(parseDeepLink('maestro:session/abc123')).toEqual({
+ action: 'session',
+ sessionId: 'abc123',
+ });
+ });
+
+ it('should handle Windows focus without double slash', () => {
+ expect(parseDeepLink('maestro:focus')).toEqual({ action: 'focus' });
+ });
+ });
+
+ describe('error handling', () => {
+ it('should return null for unrecognized resource', () => {
+ expect(parseDeepLink('maestro://unknown/abc')).toBeNull();
+ });
+
+ it('should return null for completely malformed URLs', () => {
+ // parseDeepLink is tolerant of most inputs, but unrecognized resources return null
+ expect(parseDeepLink('maestro://settings')).toBeNull();
+ });
+ });
+});
diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts
index add55b37c..a1ed411db 100644
--- a/src/__tests__/main/ipc/handlers/notifications.test.ts
+++ b/src/__tests__/main/ipc/handlers/notifications.test.ts
@@ -17,6 +17,7 @@ import { ipcMain } from 'electron';
const mocks = vi.hoisted(() => ({
mockNotificationShow: vi.fn(),
mockNotificationIsSupported: vi.fn().mockReturnValue(true),
+ mockNotificationOn: vi.fn(),
}));
// Mock electron with a proper class for Notification
@@ -29,6 +30,9 @@ vi.mock('electron', () => {
show() {
mocks.mockNotificationShow();
}
+ on(event: string, handler: () => void) {
+ mocks.mockNotificationOn(event, handler);
+ }
static isSupported() {
return mocks.mockNotificationIsSupported();
}
@@ -55,6 +59,15 @@ vi.mock('../../../../main/utils/logger', () => ({
},
}));
+// Mock deep-links module (used by notification click handler)
+vi.mock('../../../../main/deep-links', () => ({
+ parseDeepLink: vi.fn((url: string) => {
+ if (url.includes('session/')) return { action: 'session', sessionId: 'test-session' };
+ return { action: 'focus' };
+ }),
+ dispatchDeepLink: vi.fn(),
+}));
+
// Mock child_process - must include default export
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal();
@@ -99,6 +112,8 @@ import {
describe('Notification IPC Handlers', () => {
let handlers: Map;
+ const mockGetMainWindow = vi.fn().mockReturnValue(null);
+
beforeEach(() => {
vi.clearAllMocks();
resetNotificationState();
@@ -107,13 +122,14 @@ describe('Notification IPC Handlers', () => {
// Reset mocks
mocks.mockNotificationIsSupported.mockReturnValue(true);
mocks.mockNotificationShow.mockClear();
+ mocks.mockNotificationOn.mockClear();
// Capture registered handlers
vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => {
handlers.set(channel, handler);
});
- registerNotificationsHandlers();
+ registerNotificationsHandlers({ getMainWindow: mockGetMainWindow });
});
afterEach(() => {
@@ -186,6 +202,50 @@ describe('Notification IPC Handlers', () => {
});
});
+ describe('notification:show click-to-navigate', () => {
+ it('should register click handler when sessionId is provided', async () => {
+ const handler = handlers.get('notification:show')!;
+ await handler({}, 'Title', 'Body', 'session-123');
+
+ expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function));
+ });
+
+ it('should register click handler when sessionId and tabId are provided', async () => {
+ const handler = handlers.get('notification:show')!;
+ await handler({}, 'Title', 'Body', 'session-123', 'tab-456');
+
+ expect(mocks.mockNotificationOn).toHaveBeenCalledWith('click', expect.any(Function));
+ });
+
+ it('should URI-encode sessionId and tabId in deep link URL', async () => {
+ const { parseDeepLink } = await import('../../../../main/deep-links');
+ const handler = handlers.get('notification:show')!;
+ await handler({}, 'Title', 'Body', 'id/with/slashes', 'tab?special');
+
+ // Trigger the click handler
+ const clickHandler = mocks.mockNotificationOn.mock.calls[0][1];
+ clickHandler();
+
+ expect(parseDeepLink).toHaveBeenCalledWith(
+ `maestro://session/${encodeURIComponent('id/with/slashes')}/tab/${encodeURIComponent('tab?special')}`
+ );
+ });
+
+ it('should not register click handler when sessionId is not provided', async () => {
+ const handler = handlers.get('notification:show')!;
+ await handler({}, 'Title', 'Body');
+
+ expect(mocks.mockNotificationOn).not.toHaveBeenCalled();
+ });
+
+ it('should not register click handler when sessionId is undefined', async () => {
+ const handler = handlers.get('notification:show')!;
+ await handler({}, 'Title', 'Body', undefined, undefined);
+
+ expect(mocks.mockNotificationOn).not.toHaveBeenCalled();
+ });
+ });
+
describe('notification:stopSpeak', () => {
it('should return error when no active notification process', async () => {
const handler = handlers.get('notification:stopSpeak')!;
diff --git a/src/__tests__/renderer/components/TabBar.test.tsx b/src/__tests__/renderer/components/TabBar.test.tsx
index aea4c81c7..6887e1ef6 100644
--- a/src/__tests__/renderer/components/TabBar.test.tsx
+++ b/src/__tests__/renderer/components/TabBar.test.tsx
@@ -97,6 +97,11 @@ vi.mock('lucide-react', () => ({
📂
),
+ Link: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
+
+ 🔗
+
+ ),
FileText: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
📄
@@ -1278,6 +1283,75 @@ describe('TabBar', () => {
expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
});
+ it('copies deep link to clipboard when Copy Deep Link clicked', () => {
+ const tabs = [
+ createTab({
+ id: 'tab-1',
+ name: 'Tab 1',
+ agentSessionId: 'abc123-xyz789',
+ }),
+ ];
+
+ render(
+
+ );
+
+ const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
+ fireEvent.mouseEnter(tab);
+ act(() => {
+ vi.advanceTimersByTime(450);
+ });
+
+ fireEvent.click(screen.getByText('Copy Deep Link'));
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ 'maestro://session/session-42/tab/tab-1'
+ );
+ expect(screen.getByText('Copied!')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1600);
+ });
+ expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
+ });
+
+ it('does not show Copy Deep Link when sessionId not provided', () => {
+ const tabs = [
+ createTab({
+ id: 'tab-1',
+ name: 'Tab 1',
+ agentSessionId: 'abc123',
+ }),
+ ];
+
+ render(
+
+ );
+
+ const tab = screen.getByText('Tab 1').closest('[data-tab-id]')!;
+ fireEvent.mouseEnter(tab);
+ act(() => {
+ vi.advanceTimersByTime(450);
+ });
+
+ expect(screen.queryByText('Copy Deep Link')).not.toBeInTheDocument();
+ });
+
it('calls onTabStar when star button clicked', async () => {
const tabs = [
createTab({
diff --git a/src/__tests__/shared/deep-link-urls.test.ts b/src/__tests__/shared/deep-link-urls.test.ts
new file mode 100644
index 000000000..f5163aca2
--- /dev/null
+++ b/src/__tests__/shared/deep-link-urls.test.ts
@@ -0,0 +1,54 @@
+/**
+ * Tests for src/shared/deep-link-urls.ts
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ buildSessionDeepLink,
+ buildGroupDeepLink,
+ buildFocusDeepLink,
+} from '../../shared/deep-link-urls';
+
+describe('buildSessionDeepLink', () => {
+ it('should build a session-only deep link', () => {
+ expect(buildSessionDeepLink('abc123')).toBe('maestro://session/abc123');
+ });
+
+ it('should build a session + tab deep link', () => {
+ expect(buildSessionDeepLink('abc123', 'tab456')).toBe('maestro://session/abc123/tab/tab456');
+ });
+
+ it('should URI-encode session IDs with special characters', () => {
+ expect(buildSessionDeepLink('id/with/slashes')).toBe(
+ `maestro://session/${encodeURIComponent('id/with/slashes')}`
+ );
+ });
+
+ it('should URI-encode tab IDs with special characters', () => {
+ expect(buildSessionDeepLink('sess', 'tab?special')).toBe(
+ `maestro://session/sess/tab/${encodeURIComponent('tab?special')}`
+ );
+ });
+
+ it('should not include tab segment when tabId is undefined', () => {
+ expect(buildSessionDeepLink('abc123', undefined)).toBe('maestro://session/abc123');
+ });
+});
+
+describe('buildGroupDeepLink', () => {
+ it('should build a group deep link', () => {
+ expect(buildGroupDeepLink('grp789')).toBe('maestro://group/grp789');
+ });
+
+ it('should URI-encode group IDs with special characters', () => {
+ expect(buildGroupDeepLink('group/name')).toBe(
+ `maestro://group/${encodeURIComponent('group/name')}`
+ );
+ });
+});
+
+describe('buildFocusDeepLink', () => {
+ it('should build a focus deep link', () => {
+ expect(buildFocusDeepLink()).toBe('maestro://focus');
+ });
+});
diff --git a/src/__tests__/shared/templateVariables.test.ts b/src/__tests__/shared/templateVariables.test.ts
index 6d1d8c3eb..84dc4d01a 100644
--- a/src/__tests__/shared/templateVariables.test.ts
+++ b/src/__tests__/shared/templateVariables.test.ts
@@ -93,6 +93,13 @@ describe('TEMPLATE_VARIABLES constant', () => {
expect(variables).toContain('{{IS_GIT_REPO}}');
});
+ it('should include deep link variables', () => {
+ const variables = TEMPLATE_VARIABLES.map((v) => v.variable);
+ expect(variables).toContain('{{AGENT_DEEP_LINK}}');
+ expect(variables).toContain('{{TAB_DEEP_LINK}}');
+ expect(variables).toContain('{{GROUP_DEEP_LINK}}');
+ });
+
it('should mark Auto Run-only variables with autoRunOnly flag', () => {
const autoRunOnlyVars = TEMPLATE_VARIABLES.filter((v) => v.autoRunOnly);
const autoRunOnlyNames = autoRunOnlyVars.map((v) => v.variable);
@@ -565,6 +572,62 @@ describe('substituteTemplateVariables', () => {
});
});
+ describe('Deep Link Variables', () => {
+ it('should replace {{AGENT_DEEP_LINK}} with session deep link URL', () => {
+ const context = createTestContext({
+ session: createTestSession({ id: 'sess-abc' }),
+ });
+ const result = substituteTemplateVariables('Link: {{AGENT_DEEP_LINK}}', context);
+ expect(result).toBe('Link: maestro://session/sess-abc');
+ });
+
+ it('should replace {{TAB_DEEP_LINK}} with session+tab deep link when activeTabId provided', () => {
+ const context = createTestContext({
+ session: createTestSession({ id: 'sess-abc' }),
+ activeTabId: 'tab-def',
+ });
+ const result = substituteTemplateVariables('Link: {{TAB_DEEP_LINK}}', context);
+ expect(result).toBe('Link: maestro://session/sess-abc/tab/tab-def');
+ });
+
+ it('should replace {{TAB_DEEP_LINK}} with session-only link when no activeTabId', () => {
+ const context = createTestContext({
+ session: createTestSession({ id: 'sess-abc' }),
+ });
+ const result = substituteTemplateVariables('Link: {{TAB_DEEP_LINK}}', context);
+ expect(result).toBe('Link: maestro://session/sess-abc');
+ });
+
+ it('should replace {{GROUP_DEEP_LINK}} with group deep link when groupId provided', () => {
+ const context = createTestContext({
+ groupId: 'grp-789',
+ });
+ const result = substituteTemplateVariables('Link: {{GROUP_DEEP_LINK}}', context);
+ expect(result).toBe('Link: maestro://group/grp-789');
+ });
+
+ it('should replace {{GROUP_DEEP_LINK}} with empty string when no groupId', () => {
+ const context = createTestContext();
+ const result = substituteTemplateVariables('Link: {{GROUP_DEEP_LINK}}', context);
+ expect(result).toBe('Link: ');
+ });
+
+ it('should URI-encode special characters in deep link IDs', () => {
+ const context = createTestContext({
+ session: createTestSession({ id: 'id/with/slashes' }),
+ activeTabId: 'tab?special',
+ groupId: 'group#hash',
+ });
+ const agentResult = substituteTemplateVariables('{{AGENT_DEEP_LINK}}', context);
+ const tabResult = substituteTemplateVariables('{{TAB_DEEP_LINK}}', context);
+ const groupResult = substituteTemplateVariables('{{GROUP_DEEP_LINK}}', context);
+
+ expect(agentResult).toContain(encodeURIComponent('id/with/slashes'));
+ expect(tabResult).toContain(encodeURIComponent('tab?special'));
+ expect(groupResult).toContain(encodeURIComponent('group#hash'));
+ });
+ });
+
describe('Case Insensitivity', () => {
it('should handle lowercase variables', () => {
const context = createTestContext({
diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts
index 3127cac6a..20e2e19a1 100644
--- a/src/cli/services/batch-processor.ts
+++ b/src/cli/services/batch-processor.ts
@@ -397,6 +397,7 @@ export async function* runPlaybook(
},
gitBranch,
groupName,
+ groupId: session.groupId,
autoRunFolder: folderPath,
loopNumber: loopIteration + 1, // 1-indexed
documentName: docEntry.filename,
diff --git a/src/main/deep-links.ts b/src/main/deep-links.ts
new file mode 100644
index 000000000..10705f4d2
--- /dev/null
+++ b/src/main/deep-links.ts
@@ -0,0 +1,232 @@
+/**
+ * Deep Link Handler for maestro:// URL scheme
+ *
+ * Provides OS-level protocol registration and URL parsing for deep links.
+ * Enables clickable OS notifications and external app integrations.
+ *
+ * URL scheme:
+ * maestro://focus — bring window to foreground
+ * maestro://session/{sessionId} — navigate to agent
+ * maestro://session/{sessionId}/tab/{tabId} — navigate to agent + tab
+ * maestro://group/{groupId} — expand group, focus first session
+ *
+ * Platform behavior:
+ * macOS: app.on('open-url') delivers the URL
+ * Windows/Linux: app.on('second-instance') delivers argv with URL;
+ * cold start delivers via process.argv
+ */
+
+import path from 'path';
+import { app, BrowserWindow } from 'electron';
+import { logger } from './utils/logger';
+import { isWebContentsAvailable } from './utils/safe-send';
+import type { ParsedDeepLink } from '../shared/types';
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const PROTOCOL = 'maestro';
+const IPC_CHANNEL = 'app:deepLink';
+
+// ============================================================================
+// State
+// ============================================================================
+
+/** URL received before the window was ready — flushed after createWindow() */
+let pendingDeepLinkUrl: string | null = null;
+
+// ============================================================================
+// URL Parsing
+// ============================================================================
+
+/**
+ * Parse a maestro:// URL into a structured deep link object.
+ * Returns null for malformed or unrecognized URLs.
+ */
+export function parseDeepLink(url: string): ParsedDeepLink | null {
+ try {
+ // Normalize: strip protocol prefix (handles both maestro:// and maestro: on Windows)
+ const normalized = url.replace(/^maestro:\/\//, '').replace(/^maestro:/, '');
+ const parts = normalized.split('/').filter(Boolean);
+
+ if (parts.length === 0) return { action: 'focus' };
+
+ const [resource, id, sub, subId] = parts;
+
+ if (resource === 'focus') return { action: 'focus' };
+
+ if (resource === 'session' && id) {
+ if (sub === 'tab' && subId) {
+ return {
+ action: 'session',
+ sessionId: decodeURIComponent(id),
+ tabId: decodeURIComponent(subId),
+ };
+ }
+ return { action: 'session', sessionId: decodeURIComponent(id) };
+ }
+
+ if (resource === 'group' && id) {
+ return { action: 'group', groupId: decodeURIComponent(id) };
+ }
+
+ logger.warn(`Unrecognized deep link resource: ${resource}`, 'DeepLink');
+ return null;
+ } catch (error) {
+ logger.error('Failed to parse deep link URL', 'DeepLink', { url, error: String(error) });
+ return null;
+ }
+}
+
+// ============================================================================
+// Deep Link Dispatch
+// ============================================================================
+
+/**
+ * Process a deep link URL: parse it, bring window to foreground, and send to renderer.
+ */
+function processDeepLink(url: string, getMainWindow: () => BrowserWindow | null): void {
+ logger.info('Processing deep link', 'DeepLink', { url });
+
+ const parsed = parseDeepLink(url);
+ if (!parsed) return;
+
+ const win = getMainWindow();
+ if (!win) {
+ // Window not ready yet — buffer for later
+ pendingDeepLinkUrl = url;
+ logger.debug('Window not ready, buffering deep link', 'DeepLink');
+ return;
+ }
+
+ // Bring window to foreground
+ if (win.isMinimized()) win.restore();
+ win.show();
+ win.focus();
+
+ // For 'focus' action, bringing window to front is all we need
+ if (parsed.action === 'focus') return;
+
+ // Send parsed payload to renderer for navigation
+ if (isWebContentsAvailable(win)) {
+ win.webContents.send(IPC_CHANNEL, parsed);
+ }
+}
+
+// ============================================================================
+// Lifecycle Setup
+// ============================================================================
+
+/**
+ * Set up deep link protocol handling.
+ *
+ * MUST be called synchronously before app.whenReady() because
+ * requestSingleInstanceLock() only works before the app is ready.
+ *
+ * @returns false if another instance is already running (caller should app.quit())
+ */
+export function setupDeepLinkHandling(getMainWindow: () => BrowserWindow | null): boolean {
+ // Register as handler for maestro:// URLs
+ // In dev mode, skip registration to avoid clobbering the production app's registration
+ const isDev = !app.isPackaged;
+ if (!isDev) {
+ app.setAsDefaultProtocolClient(PROTOCOL);
+ logger.info('Registered as default protocol client for maestro://', 'DeepLink');
+ } else {
+ // In dev, register only if explicitly opted in
+ if (process.env.REGISTER_DEEP_LINKS_IN_DEV === '1') {
+ // In dev mode, the bare Electron binary is used. We must pass the app
+ // entry point as an argument so macOS launches Maestro, not the default
+ // Electron splash screen.
+ const appPath = path.resolve(process.argv[1]);
+ app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [appPath]);
+ logger.info(
+ `Registered protocol client in dev mode (REGISTER_DEEP_LINKS_IN_DEV=1, entry=${appPath})`,
+ 'DeepLink'
+ );
+ } else {
+ logger.debug('Skipping protocol registration in dev mode', 'DeepLink');
+ }
+ }
+
+ // Single-instance lock (Windows/Linux deep link support)
+ // On macOS, open-url handles this; on Windows/Linux, the OS launches a new instance
+ // with the URL in argv, and second-instance event fires in the primary instance
+ const gotTheLock = app.requestSingleInstanceLock();
+ if (!gotTheLock) {
+ // Another instance is running — it will receive our argv via second-instance
+ logger.info('Another instance is running, quitting', 'DeepLink');
+ return false;
+ }
+
+ // Handle second-instance event (Windows/Linux: new instance launched with deep link URL)
+ app.on('second-instance', (_event, argv) => {
+ const deepLinkUrl = argv.find(
+ (arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)
+ );
+ if (deepLinkUrl) {
+ processDeepLink(deepLinkUrl, getMainWindow);
+ } else {
+ // No deep link, but user tried to open a second instance — bring existing window to front
+ const win = getMainWindow();
+ if (win) {
+ if (win.isMinimized()) win.restore();
+ win.focus();
+ }
+ }
+ });
+
+ // Handle open-url event (macOS: OS delivers URL to running app)
+ app.on('open-url', (event, url) => {
+ event.preventDefault();
+ processDeepLink(url, getMainWindow);
+ });
+
+ // Check process.argv for cold-start deep link (Windows/Linux: app launched with URL as arg)
+ const deepLinkArg = process.argv.find(
+ (arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${PROTOCOL}:`)
+ );
+ if (deepLinkArg) {
+ pendingDeepLinkUrl = deepLinkArg;
+ logger.info('Found deep link in process argv (cold start)', 'DeepLink', { url: deepLinkArg });
+ }
+
+ return true;
+}
+
+/**
+ * Flush any pending deep link URL that arrived before the window was ready.
+ * Call this after createWindow() inside app.whenReady().
+ */
+export function flushPendingDeepLink(getMainWindow: () => BrowserWindow | null): void {
+ if (!pendingDeepLinkUrl) return;
+
+ const url = pendingDeepLinkUrl;
+ pendingDeepLinkUrl = null;
+ logger.info('Flushing pending deep link', 'DeepLink', { url });
+ processDeepLink(url, getMainWindow);
+}
+
+/**
+ * Directly dispatch a parsed deep link to the renderer.
+ * Used by notification click handlers to avoid an OS protocol round-trip.
+ */
+export function dispatchDeepLink(
+ parsed: ParsedDeepLink,
+ getMainWindow: () => BrowserWindow | null
+): void {
+ const win = getMainWindow();
+ if (!win) return;
+
+ // Bring window to foreground
+ if (win.isMinimized()) win.restore();
+ win.show();
+ win.focus();
+
+ if (parsed.action === 'focus') return;
+
+ if (isWebContentsAvailable(win)) {
+ win.webContents.send(IPC_CHANNEL, parsed);
+ }
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 0afff4436..d6d2bed13 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -91,6 +91,7 @@ import {
} from './constants';
// initAutoUpdater is now used by window-manager.ts (Phase 4 refactoring)
import { checkWslEnvironment } from './utils/wslDetector';
+import { setupDeepLinkHandling, flushPendingDeepLink } from './deep-links';
// Extracted modules (Phase 1 refactoring)
import { parseParticipantSessionId } from './group-chat/session-parser';
import { extractTextFromStreamJson } from './group-chat/output-parser';
@@ -291,6 +292,13 @@ function createWindow() {
// Set up global error handlers for uncaught exceptions (Phase 4 refactoring)
setupGlobalErrorHandlers();
+// Set up deep link protocol handling (must be before app.whenReady for requestSingleInstanceLock)
+const gotSingleInstanceLock = setupDeepLinkHandling(() => mainWindow);
+if (!gotSingleInstanceLock) {
+ app.quit();
+ process.exit(0);
+}
+
app.whenReady().then(async () => {
// Load logger settings first
const logLevel = store.get('logLevel', 'info');
@@ -394,6 +402,9 @@ app.whenReady().then(async () => {
logger.info('Creating main window', 'Startup');
createWindow();
+ // Flush any deep link URL that arrived before the window was ready (cold start)
+ flushPendingDeepLink(() => mainWindow);
+
// Note: History file watching is handled by HistoryManager.startWatching() above
// which uses the new per-session file format in the history/ directory
@@ -654,7 +665,7 @@ function setupIpcHandlers() {
registerAgentErrorHandlers();
// Register notification handlers (extracted to handlers/notifications.ts)
- registerNotificationsHandlers();
+ registerNotificationsHandlers({ getMainWindow: () => mainWindow });
// Register attachments handlers (extracted to handlers/attachments.ts)
registerAttachmentsHandlers({ app });
diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts
index ba41c326b..690cd4b30 100644
--- a/src/main/ipc/handlers/index.ts
+++ b/src/main/ipc/handlers/index.ts
@@ -259,7 +259,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
settingsStore: deps.settingsStore,
});
// Register notification handlers (OS notifications and TTS)
- registerNotificationsHandlers();
+ registerNotificationsHandlers({ getMainWindow: deps.getMainWindow });
// Register Symphony handlers for token donation / open source contributions
registerSymphonyHandlers({
app: deps.app,
diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts
index 95367b775..aed1e00a3 100644
--- a/src/main/ipc/handlers/notifications.ts
+++ b/src/main/ipc/handlers/notifications.ts
@@ -14,6 +14,8 @@ import { ipcMain, Notification, BrowserWindow } from 'electron';
import { spawn, type ChildProcess } from 'child_process';
import { logger } from '../../utils/logger';
import { isWebContentsAvailable } from '../../utils/safe-send';
+import { parseDeepLink, dispatchDeepLink } from '../../deep-links';
+import { buildSessionDeepLink } from '../../../shared/deep-link-urls';
// ==========================================================================
// Constants
@@ -329,14 +331,27 @@ async function processNextNotification(): Promise {
// Handler Registration
// ==========================================================================
+/**
+ * Dependencies for notification handlers
+ */
+export interface NotificationsHandlerDependencies {
+ getMainWindow: () => BrowserWindow | null;
+}
+
/**
* Register all notification-related IPC handlers
*/
-export function registerNotificationsHandlers(): void {
- // Show OS notification
+export function registerNotificationsHandlers(deps?: NotificationsHandlerDependencies): void {
+ // Show OS notification (with optional click-to-navigate support)
ipcMain.handle(
'notification:show',
- async (_event, title: string, body: string): Promise => {
+ async (
+ _event,
+ title: string,
+ body: string,
+ sessionId?: string,
+ tabId?: string
+ ): Promise => {
try {
if (Notification.isSupported()) {
const notification = new Notification({
@@ -344,8 +359,21 @@ export function registerNotificationsHandlers(): void {
body,
silent: true, // Don't play system sound - we have our own audio feedback option
});
+
+ // Wire click handler for navigation if session context is provided
+ if (sessionId && deps?.getMainWindow) {
+ const deepLinkUrl = buildSessionDeepLink(sessionId, tabId);
+
+ notification.on('click', () => {
+ const parsed = parseDeepLink(deepLinkUrl);
+ if (parsed) {
+ dispatchDeepLink(parsed, deps.getMainWindow);
+ }
+ });
+ }
+
notification.show();
- logger.debug('Showed OS notification', 'Notification', { title, body });
+ logger.debug('Showed OS notification', 'Notification', { title, body, sessionId, tabId });
return { success: true };
} else {
logger.warn('OS notifications not supported on this platform', 'Notification');
diff --git a/src/main/preload/index.ts b/src/main/preload/index.ts
index e91a1f1f8..6749e8a66 100644
--- a/src/main/preload/index.ts
+++ b/src/main/preload/index.ts
@@ -303,6 +303,7 @@ export type {
ShellInfo,
UpdateStatus,
} from './system';
+export type { ParsedDeepLink } from '../../shared/types';
export type {
// From sshRemote
SshRemoteApi,
diff --git a/src/main/preload/notifications.ts b/src/main/preload/notifications.ts
index 1f1f3d415..162de0187 100644
--- a/src/main/preload/notifications.ts
+++ b/src/main/preload/notifications.ts
@@ -35,9 +35,11 @@ export function createNotificationApi() {
* Show an OS notification
* @param title - Notification title
* @param body - Notification body text
+ * @param sessionId - Optional session ID for click-to-navigate
+ * @param tabId - Optional tab ID for click-to-navigate
*/
- show: (title: string, body: string): Promise =>
- ipcRenderer.invoke('notification:show', title, body),
+ show: (title: string, body: string, sessionId?: string, tabId?: string): Promise =>
+ ipcRenderer.invoke('notification:show', title, body, sessionId, tabId),
/**
* Execute a custom notification command (e.g., TTS, logging)
diff --git a/src/main/preload/system.ts b/src/main/preload/system.ts
index 14303a269..c88cb5c01 100644
--- a/src/main/preload/system.ts
+++ b/src/main/preload/system.ts
@@ -5,6 +5,7 @@
*/
import { ipcRenderer } from 'electron';
+import type { ParsedDeepLink } from '../../shared/types';
/**
* Shell information
@@ -202,6 +203,16 @@ export function createAppApi() {
ipcRenderer.on('app:systemResume', handler);
return () => ipcRenderer.removeListener('app:systemResume', handler);
},
+ /**
+ * Listen for deep link navigation events (maestro:// URLs)
+ * Fired when the app is activated via a deep link from OS notification clicks,
+ * external apps, or CLI commands.
+ */
+ onDeepLink: (callback: (deepLink: ParsedDeepLink) => void): (() => void) => {
+ const handler = (_: unknown, deepLink: ParsedDeepLink) => callback(deepLink);
+ ipcRenderer.on('app:deepLink', handler);
+ return () => ipcRenderer.removeListener('app:deepLink', handler);
+ },
};
}
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index aa7525946..9380b8487 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1504,6 +1504,41 @@ function MaestroConsoleInner() {
[setActiveSessionId]
);
+ // Keep a ref to sessions so the deep link listener doesn't churn on every sessions change
+ const sessionsRef = useRef(sessions);
+ useEffect(() => {
+ sessionsRef.current = sessions;
+ }, [sessions]);
+
+ // Deep link navigation handler — processes maestro:// URLs from OS notifications,
+ // external apps, and CLI commands
+ useEffect(() => {
+ const unsubscribe = window.maestro.app.onDeepLink((deepLink) => {
+ if (deepLink.action === 'focus') {
+ // Window already brought to foreground by main process
+ return;
+ }
+ if (deepLink.action === 'session' && deepLink.sessionId) {
+ const targetExists = sessionsRef.current.some((s) => s.id === deepLink.sessionId);
+ if (!targetExists) return;
+ handleToastSessionClick(deepLink.sessionId, deepLink.tabId);
+ return;
+ }
+ if (deepLink.action === 'group' && deepLink.groupId) {
+ // Find first session in group and navigate to it
+ const groupSession = sessionsRef.current.find((s) => s.groupId === deepLink.groupId);
+ if (groupSession) {
+ handleToastSessionClick(groupSession.id);
+ }
+ // Expand the group if it's collapsed
+ setGroups((prev) =>
+ prev.map((g) => (g.id === deepLink.groupId ? { ...g, collapsed: false } : g))
+ );
+ }
+ });
+ return unsubscribe;
+ }, [handleToastSessionClick, setGroups]);
+
// --- SESSION SORTING ---
// Extracted hook for sorted and visible session lists (ignores leading emojis for alphabetization)
const { sortedSessions, visibleSessions } = useSortedSessions({
@@ -1721,6 +1756,7 @@ function MaestroConsoleInner() {
message: prDetails.title,
actionUrl: prDetails.url,
actionLabel: prDetails.url,
+ sessionId: session?.id,
});
// Add history entry with PR details
if (session) {
diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx
index f68b600ff..e514cd6fe 100644
--- a/src/renderer/components/MainPanel.tsx
+++ b/src/renderer/components/MainPanel.tsx
@@ -1464,6 +1464,7 @@ export const MainPanel = React.memo(
tabs={activeSession.aiTabs}
activeTabId={activeSession.activeTabId}
theme={theme}
+ sessionId={activeSession.id}
onTabSelect={onTabSelect}
onTabClose={onTabClose}
onNewTab={onNewTab}
diff --git a/src/renderer/components/TabBar.tsx b/src/renderer/components/TabBar.tsx
index 6a798672b..37c027294 100644
--- a/src/renderer/components/TabBar.tsx
+++ b/src/renderer/components/TabBar.tsx
@@ -20,6 +20,7 @@ import {
Loader2,
ExternalLink,
FolderOpen,
+ Link,
} from 'lucide-react';
import type { AITab, Theme, FilePreviewTab, UnifiedTab } from '../types';
import { hasDraft } from '../utils/tabHelpers';
@@ -27,11 +28,14 @@ import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { getExtensionColor } from '../utils/extensionColors';
import { getRevealLabel } from '../utils/platformUtils';
import { safeClipboardWrite } from '../utils/clipboard';
+import { buildSessionDeepLink } from '../../shared/deep-link-urls';
interface TabBarProps {
tabs: AITab[];
activeTabId: string;
theme: Theme;
+ /** The Maestro session/agent ID that owns these tabs */
+ sessionId?: string;
onTabSelect: (tabId: string) => void;
onTabClose: (tabId: string) => void;
onNewTab: () => void;
@@ -87,6 +91,8 @@ interface TabProps {
tabId: string;
isActive: boolean;
theme: Theme;
+ /** The Maestro session/agent ID that owns these tabs */
+ sessionId?: string;
canClose: boolean;
/** Stable callback - receives tabId as first argument */
onSelect: (tabId: string) => void;
@@ -197,6 +203,7 @@ const Tab = memo(function Tab({
tabId,
isActive,
theme,
+ sessionId,
canClose,
onSelect,
onClose,
@@ -231,7 +238,7 @@ const Tab = memo(function Tab({
}: TabProps) {
const [isHovered, setIsHovered] = useState(false);
const [overlayOpen, setOverlayOpen] = useState(false);
- const [showCopied, setShowCopied] = useState(false);
+ const [showCopied, setShowCopied] = useState<'sessionId' | 'deepLink' | false>(false);
const [overlayPosition, setOverlayPosition] = useState<{
top: number;
left: number;
@@ -311,13 +318,25 @@ const Tab = memo(function Tab({
e.stopPropagation();
if (tab.agentSessionId) {
safeClipboardWrite(tab.agentSessionId);
- setShowCopied(true);
+ setShowCopied('sessionId');
setTimeout(() => setShowCopied(false), 1500);
}
},
[tab.agentSessionId]
);
+ const handleCopyDeepLink = useCallback(
+ (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (sessionId) {
+ safeClipboardWrite(buildSessionDeepLink(sessionId, tabId));
+ setShowCopied('deepLink');
+ setTimeout(() => setShowCopied(false), 1500);
+ }
+ },
+ [sessionId, tabId]
+ );
+
const handleStarClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
@@ -692,7 +711,19 @@ const Tab = memo(function Tab({
title={`Full ID: ${tab.agentSessionId}`}
>
- {showCopied ? 'Copied!' : 'Copy Session ID'}
+ {showCopied === 'sessionId' ? 'Copied!' : 'Copy Session ID'}
+
+ )}
+
+ {sessionId && (
+
)}
@@ -1508,6 +1539,7 @@ function TabBarInner({
tabs,
activeTabId,
theme,
+ sessionId,
onTabSelect,
onTabClose,
onNewTab,
@@ -1946,6 +1978,7 @@ function TabBarInner({
tabId={tab.id}
isActive={isActive}
theme={theme}
+ sessionId={sessionId}
canClose={canClose}
onSelect={onTabSelect}
onClose={onTabClose}
@@ -2065,6 +2098,7 @@ function TabBarInner({
tabId={tab.id}
isActive={isActive}
theme={theme}
+ sessionId={sessionId}
canClose={canClose}
onSelect={onTabSelect}
onClose={onTabClose}
diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts
index b0b64c7c4..6a7995de8 100644
--- a/src/renderer/global.d.ts
+++ b/src/renderer/global.d.ts
@@ -1035,6 +1035,15 @@ interface MaestroAPI {
confirmQuit: () => void;
cancelQuit: () => void;
onSystemResume: (callback: () => void) => () => void;
+ /** @see ParsedDeepLink in src/shared/types.ts — keep in sync */
+ onDeepLink: (
+ callback: (deepLink: {
+ action: 'focus' | 'session' | 'group';
+ sessionId?: string;
+ tabId?: string;
+ groupId?: string;
+ }) => void
+ ) => () => void;
};
platform: string;
logger: {
@@ -1315,7 +1324,12 @@ interface MaestroAPI {
reload: () => Promise;
};
notification: {
- show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>;
+ show: (
+ title: string,
+ body: string,
+ sessionId?: string,
+ tabId?: string
+ ) => Promise<{ success: boolean; error?: string }>;
speak: (
text: string,
command?: string
diff --git a/src/renderer/hooks/agent/useMergeTransferHandlers.ts b/src/renderer/hooks/agent/useMergeTransferHandlers.ts
index d3eb80d9a..714fe679f 100644
--- a/src/renderer/hooks/agent/useMergeTransferHandlers.ts
+++ b/src/renderer/hooks/agent/useMergeTransferHandlers.ts
@@ -186,6 +186,8 @@ export function useMergeTransferHandlers(
message: `"${result.sourceSessionName || 'Current Session'}" → "${
result.targetSessionName || 'Selected Session'
}"${tokenInfo}.${savedInfo}`,
+ sessionId: result.targetSessionId,
+ tabId: result.targetTabId,
});
// Clear the merge state for the source tab
@@ -220,6 +222,7 @@ export function useMergeTransferHandlers(
type: 'success',
title: 'Context Transferred',
message: `Created "${sessionName}" with transferred context`,
+ sessionId,
});
// Show desktop notification for visibility when app is not focused
@@ -474,6 +477,8 @@ You are taking over this conversation. Based on the context above, provide a bri
const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, {
session: targetSession,
gitBranch,
+ groupId: targetSession.groupId,
+ activeTabId: newTabId,
conductorProfile,
});
effectivePrompt = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${effectivePrompt}`;
diff --git a/src/renderer/hooks/agent/useSummarizeAndContinue.ts b/src/renderer/hooks/agent/useSummarizeAndContinue.ts
index bc211bf0b..8980ec947 100644
--- a/src/renderer/hooks/agent/useSummarizeAndContinue.ts
+++ b/src/renderer/hooks/agent/useSummarizeAndContinue.ts
@@ -347,6 +347,8 @@ export function useSummarizeAndContinue(session: Session | null): UseSummarizeAn
type: 'warning',
title: 'Cannot Compact',
message: `Context too small. Need at least ${contextSummarizationService.getMinContextUsagePercent()}% usage, ~2k tokens, or 8+ messages to compact.`,
+ sessionId,
+ tabId: targetTabId,
});
return;
}
diff --git a/src/renderer/hooks/batch/useDocumentProcessor.ts b/src/renderer/hooks/batch/useDocumentProcessor.ts
index 3fa69365a..f0e173694 100644
--- a/src/renderer/hooks/batch/useDocumentProcessor.ts
+++ b/src/renderer/hooks/batch/useDocumentProcessor.ts
@@ -297,6 +297,8 @@ export function useDocumentProcessor(): UseDocumentProcessorReturn {
session,
gitBranch,
groupName,
+ groupId: session.groupId,
+ activeTabId: session.activeTabId,
autoRunFolder: folderPath,
loopNumber: loopIteration, // Already 1-indexed from caller
documentName: filename,
diff --git a/src/renderer/hooks/input/useInputProcessing.ts b/src/renderer/hooks/input/useInputProcessing.ts
index 095db9375..d6c2a87a2 100644
--- a/src/renderer/hooks/input/useInputProcessing.ts
+++ b/src/renderer/hooks/input/useInputProcessing.ts
@@ -243,6 +243,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
substituteTemplateVariables(matchingCustomCommand.prompt, {
session: activeSession,
gitBranch,
+ groupId: activeSession.groupId,
+ activeTabId: activeSession.activeTabId,
conductorProfile,
});
@@ -976,6 +978,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, {
session: freshSession,
gitBranch,
+ groupId: freshSession.groupId,
+ activeTabId: freshSession.activeTabId,
historyFilePath,
conductorProfile,
});
diff --git a/src/renderer/hooks/remote/useRemoteHandlers.ts b/src/renderer/hooks/remote/useRemoteHandlers.ts
index bc1885178..ceff1067f 100644
--- a/src/renderer/hooks/remote/useRemoteHandlers.ts
+++ b/src/renderer/hooks/remote/useRemoteHandlers.ts
@@ -284,6 +284,8 @@ export function useRemoteHandlers(deps: UseRemoteHandlersDeps): UseRemoteHandler
promptToSend = substituteTemplateVariables(matchingCommand.prompt, {
session,
gitBranch,
+ groupId: session.groupId,
+ activeTabId: session.activeTabId,
conductorProfile,
});
commandMetadata = {
diff --git a/src/renderer/stores/agentStore.ts b/src/renderer/stores/agentStore.ts
index 120376284..853e9bc88 100644
--- a/src/renderer/stores/agentStore.ts
+++ b/src/renderer/stores/agentStore.ts
@@ -317,6 +317,8 @@ export const useAgentStore = create()((set, get) => ({
const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, {
session,
gitBranch,
+ groupId: session.groupId,
+ activeTabId: targetTab.id,
conductorProfile: deps.conductorProfile,
});
@@ -387,6 +389,8 @@ export const useAgentStore = create()((set, get) => ({
const substitutedPrompt = substituteTemplateVariables(promptWithArgs, {
session,
gitBranch,
+ groupId: session.groupId,
+ activeTabId: targetTab.id,
conductorProfile: deps.conductorProfile,
});
@@ -397,6 +401,8 @@ export const useAgentStore = create()((set, get) => ({
const substitutedSystemPrompt = substituteTemplateVariables(maestroSystemPrompt, {
session,
gitBranch,
+ groupId: session.groupId,
+ activeTabId: targetTab.id,
conductorProfile: deps.conductorProfile,
});
promptForAgent = `${substitutedSystemPrompt}\n\n---\n\n# User Request\n\n${substitutedPrompt}`;
diff --git a/src/renderer/stores/notificationStore.ts b/src/renderer/stores/notificationStore.ts
index 5b7bd7d64..d380fe3c6 100644
--- a/src/renderer/stores/notificationStore.ts
+++ b/src/renderer/stores/notificationStore.ts
@@ -265,7 +265,7 @@ export function notifyToast(toast: Omit): string {
const prefix = bodyParts.length > 0 ? `${bodyParts.join(' > ')}: ` : '';
const notifBody = prefix + firstSentence;
- window.maestro.notification.show(notifTitle, notifBody).catch((err) => {
+ window.maestro.notification.show(notifTitle, notifBody, toast.sessionId, toast.tabId).catch((err) => {
console.error('[notificationStore] Failed to show OS notification:', err);
});
}
diff --git a/src/shared/deep-link-urls.ts b/src/shared/deep-link-urls.ts
new file mode 100644
index 000000000..d6383e440
--- /dev/null
+++ b/src/shared/deep-link-urls.ts
@@ -0,0 +1,33 @@
+/**
+ * Deep Link URL Builders
+ *
+ * Shared utilities for constructing maestro:// URLs with proper URI encoding.
+ * Used by both main process (notification click handlers) and shared modules
+ * (template variable substitution).
+ */
+
+const PROTOCOL = 'maestro://';
+
+/**
+ * Build a deep link URL for a session, optionally targeting a specific tab.
+ */
+export function buildSessionDeepLink(sessionId: string, tabId?: string): string {
+ if (tabId) {
+ return `${PROTOCOL}session/${encodeURIComponent(sessionId)}/tab/${encodeURIComponent(tabId)}`;
+ }
+ return `${PROTOCOL}session/${encodeURIComponent(sessionId)}`;
+}
+
+/**
+ * Build a deep link URL for a group.
+ */
+export function buildGroupDeepLink(groupId: string): string {
+ return `${PROTOCOL}group/${encodeURIComponent(groupId)}`;
+}
+
+/**
+ * Build a deep link URL that simply brings the window to foreground.
+ */
+export function buildFocusDeepLink(): string {
+ return `${PROTOCOL}focus`;
+}
diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts
index 39ecae010..afd395c22 100644
--- a/src/shared/templateVariables.ts
+++ b/src/shared/templateVariables.ts
@@ -1,3 +1,5 @@
+import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls';
+
/**
* Template Variable System for Auto Run and Custom AI Commands
*
@@ -40,6 +42,11 @@
* {{GIT_BRANCH}} - Current git branch name (requires git repo)
* {{IS_GIT_REPO}} - "true" or "false"
*
+ * Deep Link Variables:
+ * {{AGENT_DEEP_LINK}} - maestro:// deep link to this agent
+ * {{TAB_DEEP_LINK}} - maestro:// deep link to this agent + active tab
+ * {{GROUP_DEEP_LINK}} - maestro:// deep link to this agent's group (if grouped)
+ *
* Context Variables:
* {{CONTEXT_USAGE}} - Current context window usage percentage
*/
@@ -64,6 +71,8 @@ export interface TemplateContext {
session: TemplateSessionInfo;
gitBranch?: string;
groupName?: string;
+ groupId?: string;
+ activeTabId?: string;
autoRunFolder?: string;
loopNumber?: number;
// Auto Run document context
@@ -78,6 +87,7 @@ export interface TemplateContext {
// 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_DEEP_LINK}}', description: 'Deep link to this agent (maestro://)' },
{ variable: '{{AGENT_GROUP}}', description: 'Agent group name' },
{ variable: '{{CONDUCTOR_PROFILE}}', description: "Conductor's About Me profile" },
{ variable: '{{AGENT_HISTORY_PATH}}', description: 'History file path (task recall)' },
@@ -95,6 +105,7 @@ export const TEMPLATE_VARIABLES = [
{ variable: '{{DOCUMENT_NAME}}', description: 'Current document name', autoRunOnly: true },
{ variable: '{{DOCUMENT_PATH}}', description: 'Current document path', autoRunOnly: true },
{ variable: '{{GIT_BRANCH}}', description: 'Git branch name' },
+ { variable: '{{GROUP_DEEP_LINK}}', description: 'Deep link to agent group (maestro://)' },
{ variable: '{{IS_GIT_REPO}}', description: 'Is git repo (true/false)' },
{
variable: '{{LOOP_NUMBER}}',
@@ -102,6 +113,7 @@ export const TEMPLATE_VARIABLES = [
autoRunOnly: true,
},
{ variable: '{{MONTH}}', description: 'Month (01-12)' },
+ { variable: '{{TAB_DEEP_LINK}}', description: 'Deep link to agent + active tab (maestro://)' },
{ variable: '{{TIME}}', description: 'Time (HH:MM:SS)' },
{ variable: '{{TIMESTAMP}}', description: 'Unix timestamp (ms)' },
{ variable: '{{TIME_SHORT}}', description: 'Time (HH:MM)' },
@@ -121,6 +133,8 @@ export function substituteTemplateVariables(template: string, context: TemplateC
session,
gitBranch,
groupName,
+ groupId,
+ activeTabId,
autoRunFolder,
loopNumber,
documentName,
@@ -181,6 +195,11 @@ export function substituteTemplateVariables(template: string, context: TemplateC
GIT_BRANCH: gitBranch || '',
IS_GIT_REPO: String(session.isGitRepo ?? false),
+ // Deep link variables
+ AGENT_DEEP_LINK: buildSessionDeepLink(session.id),
+ TAB_DEEP_LINK: buildSessionDeepLink(session.id, activeTabId),
+ GROUP_DEEP_LINK: groupId ? buildGroupDeepLink(groupId) : '',
+
// Context variables
CONTEXT_USAGE: String(session.contextUsage || 0),
};
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 24d4da21e..30ab748af 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -365,6 +365,25 @@ export interface AgentSshRemoteConfig {
workingDirOverride?: string;
}
+// ============================================================================
+// Deep Link Types
+// ============================================================================
+
+/**
+ * Parsed deep link from a maestro:// URL.
+ * Used by both main process (URL parsing) and renderer (navigation dispatch).
+ */
+export interface ParsedDeepLink {
+ /** The type of navigation action */
+ action: 'focus' | 'session' | 'group';
+ /** Maestro session ID (for action: 'session') */
+ sessionId?: string;
+ /** Tab ID within the session (for action: 'session') */
+ tabId?: string;
+ /** Group ID (for action: 'group') */
+ groupId?: string;
+}
+
// ============================================================================
// Global Agent Statistics Types
// ============================================================================