diff --git a/docs/development.md b/docs/development.md index 81e43cb3b..9eed78a00 100644 --- a/docs/development.md +++ b/docs/development.md @@ -390,6 +390,7 @@ npm run cli -- ls -a -g # List all agents globally npm run cli -- ls -a -g --json # Same, as JSON npm run cli -- inspect # Show detailed agent info npm run cli -- logs # View agent timeline +npm run cli -- agent open # Focus an existing agent in Paseo Desktop npm run cli -- daemon status # Check daemon status npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project ``` @@ -400,6 +401,11 @@ Use `--host ` to point the CLI at a different daemon: npm run cli -- --host localhost:7777 ls -a ``` +Desktop integrations can focus an existing agent without creating one or +sending a message. Use `paseo://h//agent/`, or run +`paseo agent open `. The CLI reads the local daemon's server ID by +default; pass `--server ` when targeting another server. + ## Agent state Agent data lives at: diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 5c3909798..c3346e927 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -66,6 +66,7 @@ import { import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; import { ThemedStack } from "@/navigation/themed-stack"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { AgentNavigationListener } from "@/desktop/agent-navigation"; import { listenToDesktopEvent } from "@/desktop/electron/events"; import { updateDesktopWindowControls } from "@/desktop/electron/window"; import { getDesktopHost } from "@/desktop/host"; @@ -908,6 +909,7 @@ function AppShell() { + diff --git a/packages/app/src/desktop/agent-navigation.tsx b/packages/app/src/desktop/agent-navigation.tsx new file mode 100644 index 000000000..f85d09cac --- /dev/null +++ b/packages/app/src/desktop/agent-navigation.tsx @@ -0,0 +1,69 @@ +import { useEffect } from "react"; +import { listenToDesktopEvent } from "@/desktop/electron/events"; +import { getDesktopHost } from "@/desktop/host"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; + +interface OpenAgentEventPayload { + serverId?: unknown; + agentId?: unknown; +} + +export function AgentNavigationListener() { + const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => { + const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : ""; + const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : ""; + if (!serverId || !agentId) { + return; + } + navigateToAgent({ serverId, agentId }); + }); + + useEffect(() => { + const host = getDesktopHost(); + const ready = host?.agentNavigation?.ready; + if (typeof host?.events?.on !== "function" || typeof ready !== "function") { + return; + } + + let disposed = false; + let unlisten: (() => void) | null = null; + let retryTimer: ReturnType | null = null; + + const connect = async () => { + let dispose: (() => void) | null = null; + try { + dispose = await listenToDesktopEvent("open-agent", openAgent); + if (disposed) { + dispose(); + return; + } + unlisten = dispose; + const pending = await ready(); + if (!disposed && pending) { + openAgent(pending); + } + } catch { + dispose?.(); + if (unlisten === dispose) { + unlisten = null; + } + if (!disposed) { + retryTimer = setTimeout(() => void connect(), 1_000); + } + } + }; + + void connect(); + + return () => { + disposed = true; + if (retryTimer) { + clearTimeout(retryTimer); + } + unlisten?.(); + }; + }, [openAgent]); + + return null; +} diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index cb750af12..152f32abc 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -123,6 +123,10 @@ export interface DesktopEventsBridge { on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void); } +export interface DesktopAgentNavigationBridge { + ready?: () => Promise<{ serverId: string; agentId: string } | null>; +} + export type DesktopBrowserShortcutEvent = | { browserId?: string; action: "focus-url" } | { browserId: string; action: "new-tab" }; @@ -169,6 +173,7 @@ export interface DesktopHostBridge { platform?: string; invoke?: DesktopInvokeBridge["invoke"]; getPendingOpenProject?: () => Promise; + agentNavigation?: DesktopAgentNavigationBridge; events?: DesktopEventsBridge; window?: DesktopWindowModuleBridge; dialog?: DesktopDialogBridge; diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 769d2e4a6..9011c23b3 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -34,4 +34,12 @@ describe("canonical CLI surface", () => { expect(run?.helpInformation()).toContain("--background"); expect(run?.helpInformation()).not.toContain("--detach"); }); + + it("offers opening an existing agent in the desktop app", () => { + const agent = createCli().commands.find((command) => command.name() === "agent"); + const open = agent?.commands.find((command) => command.name() === "open"); + + expect(open?.helpInformation()).toContain(""); + expect(open?.helpInformation()).toContain("--server "); + }); }); diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 37d8f6949..a0fd5d5de 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -14,6 +14,7 @@ import { addReloadOptions, runReloadCommand } from "./reload.js"; import { addImportOptions, runImportCommand } from "./import.js"; import { runUpdateCommand } from "./update.js"; import { runDetachCommand } from "./detach.js"; +import { addOpenOptions, runOpenCommand } from "./open.js"; import { withOutput } from "../../output/index.js"; import { addDaemonHostOption, @@ -39,6 +40,10 @@ export function createAgentCommand(): Command { addDaemonHostOption(addLogsOptions(agent.command("logs"))).action(runLogsCommand); + addJsonAndDaemonHostOptions(addOpenOptions(agent.command("open"))).action( + withOutput(runOpenCommand), + ); + addJsonAndDaemonHostOptions(addStopOptions(agent.command("stop"))).action( withOutput(runStopCommand), ); diff --git a/packages/cli/src/commands/agent/open.ts b/packages/cli/src/commands/agent/open.ts new file mode 100644 index 000000000..333ec61b4 --- /dev/null +++ b/packages/cli/src/commands/agent/open.ts @@ -0,0 +1,82 @@ +import type { Command } from "commander"; +import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { openDesktopWithAgent } from "../open.js"; +import type { + CommandError, + CommandOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; + +interface OpenAgentResult { + agentId: string; + serverId: string; + status: "opened"; +} + +const openAgentSchema: OutputSchema = { + idField: "agentId", + columns: [ + { header: "AGENT ID", field: "agentId" }, + { header: "SERVER ID", field: "serverId" }, + { header: "STATUS", field: "status" }, + ], +}; + +export function addOpenOptions(command: Command): Command { + return command + .description("Open an existing agent in Paseo Desktop") + .argument("", "Existing agent ID") + .option("--server ", "Server ID (defaults to the local daemon)"); +} + +async function resolveServerId(options: CommandOptions): Promise { + const explicitServerId = typeof options.server === "string" ? options.server.trim() : ""; + if (explicitServerId) { + return explicitServerId; + } + + let client; + try { + client = await connectToDaemon({ host: options.host }); + } catch (error) { + throw buildDaemonConnectionCommandError({ host: options.host, error }); + } + try { + const serverId = client.getLastServerInfoMessage()?.serverId.trim(); + if (!serverId) { + const error: CommandError = { + code: "SERVER_ID_UNAVAILABLE", + message: "The daemon did not report a server ID.", + }; + throw error; + } + return serverId; + } finally { + await client.close().catch(() => {}); + } +} + +export async function runOpenCommand( + agentIdArg: string, + options: CommandOptions, + _command: Command, +): Promise> { + const agentId = agentIdArg.trim(); + if (!agentId) { + const error: CommandError = { + code: "MISSING_AGENT_ID", + message: "Agent ID is required.", + }; + throw error; + } + + const serverId = await resolveServerId(options); + await openDesktopWithAgent({ serverId, agentId }); + + return { + type: "single", + data: { agentId, serverId, status: "opened" }, + schema: openAgentSchema, + }; +} diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 87ff16c9d..c815dd36f 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; import { spawnProcess } from "@getpaseo/server"; +import { buildAgentDeepLink, type AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; function findDesktopApp(): string | null { if (process.platform === "darwin") { @@ -67,35 +68,39 @@ function spawnDetached(command: string, args: string[]): void { }).unref(); } +function launchDesktop(args: string[]): void { + if (process.env.PASEO_DESKTOP_CLI === "1") { + throw new Error("Cannot open Paseo Desktop while running in desktop CLI passthrough mode."); + } + + const desktopApp = findDesktopApp(); + if (!desktopApp) { + throw new Error( + "Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases", + ); + } + + if (process.platform === "darwin") { + // -n forces a new instance even if the app is already running. The new + // instance relays its argv to the existing one through Electron's + // single-instance lock. -g keeps the terminal in the foreground. + spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", ...args]); + return; + } + + spawnDetached(desktopApp, args); +} + export async function openDesktopWithProject(projectPath: string): Promise { try { - if (process.env.PASEO_DESKTOP_CLI === "1") { - throw new Error( - "Cannot open a desktop project while running in desktop CLI passthrough mode.", - ); - } - - const desktopApp = findDesktopApp(); - if (!desktopApp) { - throw new Error( - "Paseo desktop app not found. Install it from https://github.com/getpaseo/paseo/releases", - ); - } - - if (process.platform === "darwin") { - // -n forces a new instance even if the app is already running. - // The new instance hits requestSingleInstanceLock(), fails, and relays - // the argv to the first instance via the second-instance event. - // -g keeps the terminal in the foreground (better CLI UX). - // Without -n, macOS just activates the existing window and drops --args. - spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]); - return; - } - - spawnDetached(desktopApp, [projectPath]); + launchDesktop([projectPath]); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; } } + +export async function openDesktopWithAgent(target: AgentDeepLinkTarget): Promise { + launchDesktop([buildAgentDeepLink(target)]); +} diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 53583c8e9..bbee5e034 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -2,6 +2,10 @@ npmRebuild: false appId: sh.paseo.desktop productName: Paseo executableName: Paseo +protocols: + - name: Paseo agent link + schemes: + - paseo afterPack: ./scripts/after-pack.js afterSign: ./scripts/after-sign.js directories: diff --git a/packages/desktop/src/agent-navigation.test.ts b/packages/desktop/src/agent-navigation.test.ts new file mode 100644 index 000000000..4be345421 --- /dev/null +++ b/packages/desktop/src/agent-navigation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { AgentNavigationInbox, parseAgentDeepLinkFromArgv } from "./agent-navigation.js"; + +describe("desktop agent navigation", () => { + it("finds an agent deep link among Electron launch arguments", () => { + expect( + parseAgentDeepLinkFromArgv([ + "/Applications/Paseo.app/Contents/MacOS/Paseo", + "--no-sandbox", + "paseo://h/server-1/agent/agent-2", + ]), + ).toEqual({ serverId: "server-1", agentId: "agent-2" }); + }); + + it("holds navigation until the existing renderer is ready", () => { + const inbox = new AgentNavigationInbox(); + const target = { serverId: "server-1", agentId: "agent-2" }; + + expect(inbox.deliverOrQueue(7, target)).toBeNull(); + expect(inbox.windowReady(7)).toEqual(target); + expect(inbox.deliverOrQueue(7, target)).toEqual(target); + }); + + it("returns only the newest navigation queued during startup", () => { + const inbox = new AgentNavigationInbox(); + + inbox.deliverOrQueue(7, { serverId: "server-1", agentId: "agent-1" }); + inbox.deliverOrQueue(7, { serverId: "server-1", agentId: "agent-2" }); + + expect(inbox.windowReady(7)).toEqual({ serverId: "server-1", agentId: "agent-2" }); + expect(inbox.windowReady(7)).toBeNull(); + }); +}); diff --git a/packages/desktop/src/agent-navigation.ts b/packages/desktop/src/agent-navigation.ts new file mode 100644 index 000000000..9ebc0823a --- /dev/null +++ b/packages/desktop/src/agent-navigation.ts @@ -0,0 +1,40 @@ +import { parseAgentDeepLink, type AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; + +export function parseAgentDeepLinkFromArgv(argv: string[]): AgentDeepLinkTarget | null { + for (const arg of argv) { + const target = parseAgentDeepLink(arg); + if (target) { + return target; + } + } + return null; +} + +export class AgentNavigationInbox { + private readonly readyWindows = new Set(); + private readonly pendingByWindow = new Map(); + + windowLoading(webContentsId: number): void { + this.readyWindows.delete(webContentsId); + } + + windowReady(webContentsId: number): AgentDeepLinkTarget | null { + this.readyWindows.add(webContentsId); + const pending = this.pendingByWindow.get(webContentsId) ?? null; + this.pendingByWindow.delete(webContentsId); + return pending; + } + + deliverOrQueue(webContentsId: number, target: AgentDeepLinkTarget): AgentDeepLinkTarget | null { + if (this.readyWindows.has(webContentsId)) { + return target; + } + this.pendingByWindow.set(webContentsId, target); + return null; + } + + removeWindow(webContentsId: number): void { + this.readyWindows.delete(webContentsId); + this.pendingByWindow.delete(webContentsId); + } +} diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index b0272d93a..8eac5ecc1 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -89,6 +89,13 @@ describe("desktop packaging", () => { expect(config).toContain("!node_modules/@getpaseo/server/dist/server/web-ui/**"); }); + it("registers Paseo agent links with the operating system", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain("name: Paseo agent link"); + expect(config).toContain("- paseo"); + }); + // electron-builder packs production dependencies declared in package.json into // app.asar. Runtime code in runtime-paths.ts and bin/paseo dynamically resolves // these workspace packages by string, so static analysis (TypeScript, Knip) cannot diff --git a/packages/desktop/src/desktop-startup.test.ts b/packages/desktop/src/desktop-startup.test.ts index c447c216d..a820efecb 100644 --- a/packages/desktop/src/desktop-startup.test.ts +++ b/packages/desktop/src/desktop-startup.test.ts @@ -5,7 +5,7 @@ describe("desktop startup", () => { it("runs CLI passthrough before GUI login-shell env inheritance", async () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: false, + hasPendingGuiLaunchRequest: false, runCliPassthroughIfRequested: vi.fn(async () => { calls.push("cli"); return true; @@ -22,7 +22,7 @@ describe("desktop startup", () => { it("keeps login-shell env inheritance on normal GUI startup", async () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: false, + hasPendingGuiLaunchRequest: false, runCliPassthroughIfRequested: vi.fn(async () => { calls.push("cli"); return false; @@ -39,7 +39,7 @@ describe("desktop startup", () => { it("starts skills auto-update after GUI startup", async () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: false, + hasPendingGuiLaunchRequest: false, runCliPassthroughIfRequested: vi.fn(async () => { calls.push("cli"); return false; @@ -59,7 +59,7 @@ describe("desktop startup", () => { const calls: string[] = []; await runDesktopStartup({ - hasPendingOpenProjectPath: true, + hasPendingGuiLaunchRequest: true, runCliPassthroughIfRequested, inheritLoginShellEnv: vi.fn(() => calls.push("env")), bootstrapGui: vi.fn(async () => { diff --git a/packages/desktop/src/desktop-startup.ts b/packages/desktop/src/desktop-startup.ts index 25943e342..d1be69217 100644 --- a/packages/desktop/src/desktop-startup.ts +++ b/packages/desktop/src/desktop-startup.ts @@ -1,5 +1,5 @@ export interface DesktopStartupDependencies { - hasPendingOpenProjectPath: boolean; + hasPendingGuiLaunchRequest: boolean; runCliPassthroughIfRequested: () => Promise; inheritLoginShellEnv: () => void; bootstrapGui: () => Promise; @@ -7,7 +7,7 @@ export interface DesktopStartupDependencies { } export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise { - if (!deps.hasPendingOpenProjectPath && (await deps.runCliPassthroughIfRequested())) { + if (!deps.hasPendingGuiLaunchRequest && (await deps.runCliPassthroughIfRequested())) { return; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index ef4e8a82e..5509fdd0e 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -90,6 +90,12 @@ import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js"; import { BrowserKeyboard } from "./features/browser-keyboard/index.js"; import { installAppUpdateOnQuit } from "./features/auto-updater.js"; +import { + buildAgentDeepLinkRoute, + parseAgentDeepLink, + type AgentDeepLinkTarget, +} from "@getpaseo/protocol/agent-deep-link"; +import { AgentNavigationInbox, parseAgentDeepLinkFromArgv } from "./agent-navigation.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -98,6 +104,16 @@ const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_L const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo"; const UPDATE_QUIT_DEADLINE_MS = 5_000; const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests(); +const agentNavigationInbox = new AgentNavigationInbox(); + +// A second-instance launch can arrive before the packaged protocol handler, +// IPC handlers, and first window exist. Wait for full bootstrap, not just +// app.whenReady(), before delivering navigation to the renderer. +let resolveBootstrapComplete: () => void; +const bootstrapComplete = new Promise((resolve) => { + resolveBootstrapComplete = resolve; +}); +let bootstrapIsComplete = false; app.setName(APP_NAME); @@ -316,6 +332,7 @@ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, }); +let pendingAgentNavigation = parseAgentDeepLinkFromArgv(process.argv); // Each window pulls its own pending open-project path on mount, keyed by // webContents id, so deep-linked windows (second-instance launches, the @@ -341,6 +358,10 @@ ipcMain.handle("paseo:get-pending-open-project", (event) => { return result; }); +ipcMain.handle("paseo:agent-navigation:ready", (event) => { + return agentNavigationInbox.windowReady(event.sender.id); +}); + function normalizeBrowserCaptureRect( rect: unknown, ): { x: number; y: number; width: number; height: number } | null { @@ -653,6 +674,7 @@ function getWorkAreasPrimaryFirst(): Electron.Rectangle[] { async function createWindow( options: { + initialRoute?: string | null; pendingOpenProjectPath?: string | null; restoreWindowState?: boolean; } = {}, @@ -694,8 +716,14 @@ async function createWindow( const webContentsId = mainWindow.webContents.id; pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath); + mainWindow.webContents.on("did-start-navigation", (_event, _url, isSameDocument, isMainFrame) => { + if (isMainFrame && !isSameDocument) { + agentNavigationInbox.windowLoading(webContentsId); + } + }); mainWindow.on("closed", () => { pendingOpenProjectStore.delete(webContentsId); + agentNavigationInbox.removeWindow(webContentsId); unregisterPaseoBrowserHost(webContentsId); browserKeyboard.detachHost(webContentsId); }); @@ -759,11 +787,14 @@ async function createWindow( if (!app.isPackaged) { const { loadReactDevTools } = await import("./features/react-devtools.js"); await loadReactDevTools(); - await mainWindow.loadURL(DEV_SERVER_URL); + const initialUrl = options.initialRoute + ? new URL(options.initialRoute, `${DEV_SERVER_URL}/`).toString() + : DEV_SERVER_URL; + await mainWindow.loadURL(initialUrl); return mainWindow; } - await mainWindow.loadURL(`${APP_SCHEME}://app/`); + await mainWindow.loadURL(`${APP_SCHEME}://app${options.initialRoute ?? "/"}`); return mainWindow; } @@ -771,14 +802,72 @@ async function createWindow( // App lifecycle // --------------------------------------------------------------------------- -// Resolves once bootstrap() has registered the custom protocol handler and IPC -// handlers and created the first window. second-instance window creation waits -// on this rather than app.whenReady(): in packaged mode createWindow loads -// `paseo://app/`, which fails if the protocol handler isn't registered yet, and -// a second instance can arrive mid-cold-start. -let resolveBootstrapComplete: () => void; -const bootstrapComplete = new Promise((resolve) => { - resolveBootstrapComplete = resolve; +let agentNavigationWindowCreation: Promise | null = null; + +function focusExistingWindowOnAgent(target: AgentDeepLinkTarget): void { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = + BrowserWindow.getFocusedWindow() ?? windows.find((window) => window.isVisible()) ?? windows[0]; + if (!mainWindow || mainWindow.isDestroyed()) { + if (!agentNavigationWindowCreation) { + const creation = createWindow({ + initialRoute: buildAgentDeepLinkRoute(target), + restoreWindowState: true, + }); + agentNavigationWindowCreation = creation; + void creation + .catch((error) => log.error("[window] failed to create window for agent link", error)) + .finally(() => { + if (agentNavigationWindowCreation === creation) { + agentNavigationWindowCreation = null; + } + }); + return; + } + + void agentNavigationWindowCreation + .then(() => focusExistingWindowOnAgent(target)) + .catch((error) => log.error("[window] failed to deliver queued agent link", error)); + return; + } + + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + + const deliverable = agentNavigationInbox.deliverOrQueue(mainWindow.webContents.id, target); + if (deliverable) { + mainWindow.webContents.send("paseo:event:open-agent", deliverable); + } +} + +function receiveAgentDeepLink(input: string): void { + const target = parseAgentDeepLink(input); + if (!target) { + return; + } + + if (bootstrapIsComplete) { + focusExistingWindowOnAgent(target); + return; + } + + pendingAgentNavigation = target; + void bootstrapComplete.then(() => { + if (pendingAgentNavigation !== target) { + return undefined; + } + pendingAgentNavigation = null; + focusExistingWindowOnAgent(target); + return undefined; + }); +} + +app.on("open-url", (event, url) => { + event.preventDefault(); + receiveAgentDeepLink(url); }); function setupSingleInstanceLock(): boolean { @@ -794,6 +883,12 @@ function setupSingleInstanceLock(): boolean { } app.on("second-instance", (_event, commandLine) => { + const agentTarget = parseAgentDeepLinkFromArgv(commandLine); + if (agentTarget) { + void bootstrapComplete.then(() => focusExistingWindowOnAgent(agentTarget)); + return; + } + log.info("[open-project] second-instance commandLine:", commandLine); const openProjectPath = parseOpenProjectPathFromArgv({ argv: commandLine, @@ -895,13 +990,26 @@ async function bootstrap(): Promise { }); // The first window of the session restores and persists saved geometry. - await createWindow({ pendingOpenProjectPath, restoreWindowState: true }); + const initialAgentNavigation = pendingAgentNavigation; + pendingAgentNavigation = null; + await createWindow({ + initialRoute: initialAgentNavigation ? buildAgentDeepLinkRoute(initialAgentNavigation) : null, + pendingOpenProjectPath, + restoreWindowState: true, + }); pendingOpenProjectPath = null; // Protocol + IPC handlers and the first window now exist: release any // second-instance launches that arrived during cold start. + bootstrapIsComplete = true; resolveBootstrapComplete(); + if (pendingAgentNavigation) { + const target = pendingAgentNavigation; + pendingAgentNavigation = null; + focusExistingWindowOnAgent(target); + } + app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { await createWindow({ restoreWindowState: true }); @@ -910,7 +1018,7 @@ async function bootstrap(): Promise { } void runDesktopStartup({ - hasPendingOpenProjectPath: Boolean(pendingOpenProjectPath), + hasPendingGuiLaunchRequest: Boolean(pendingOpenProjectPath || pendingAgentNavigation), runCliPassthroughIfRequested, inheritLoginShellEnv, bootstrapGui: bootstrap, diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index b409de37c..1267d2233 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -23,6 +23,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", { ipcRenderer.invoke("paseo:invoke", command, args), getPendingOpenProject: () => ipcRenderer.invoke("paseo:get-pending-open-project") as Promise, + agentNavigation: { + ready: () => + ipcRenderer.invoke("paseo:agent-navigation:ready") as Promise<{ + serverId: string; + agentId: string; + } | null>, + }, events: { on: (event: string, handler: EventHandler): Promise<() => void> => { const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => { diff --git a/packages/protocol/src/agent-deep-link.test.ts b/packages/protocol/src/agent-deep-link.test.ts new file mode 100644 index 000000000..a8f6ca96f --- /dev/null +++ b/packages/protocol/src/agent-deep-link.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + buildAgentDeepLink, + buildAgentDeepLinkRoute, + parseAgentDeepLink, +} from "./agent-deep-link.js"; + +describe("agent deep links", () => { + it("round-trips an existing agent target", () => { + const target = { serverId: "server/main", agentId: "agent 123" }; + + const link = buildAgentDeepLink(target); + + expect(link).toBe("paseo://h/server%2Fmain/agent/agent%20123"); + expect(buildAgentDeepLinkRoute(target)).toBe("/h/server%2Fmain/agent/agent%20123"); + expect(parseAgentDeepLink(link)).toEqual(target); + }); + + it("rejects links outside the exact agent route", () => { + expect(parseAgentDeepLink("https://h/server/agent/agent-1")).toBeNull(); + expect(parseAgentDeepLink("paseo://app/h/server/agent/agent-1")).toBeNull(); + expect(parseAgentDeepLink("paseo://h/server/agent/agent-1?message=hello")).toBeNull(); + expect(parseAgentDeepLink("paseo://h/server/agent/agent-1/extra")).toBeNull(); + }); +}); diff --git a/packages/protocol/src/agent-deep-link.ts b/packages/protocol/src/agent-deep-link.ts new file mode 100644 index 000000000..bb4980ffe --- /dev/null +++ b/packages/protocol/src/agent-deep-link.ts @@ -0,0 +1,56 @@ +export interface AgentDeepLinkTarget { + serverId: string; + agentId: string; +} + +function normalizeSegment(value: string): string { + return value.trim(); +} + +export function buildAgentDeepLink(target: AgentDeepLinkTarget): string { + const serverId = normalizeSegment(target.serverId); + const agentId = normalizeSegment(target.agentId); + if (!serverId || !agentId) { + throw new Error("Agent deep links require a server ID and agent ID."); + } + return `paseo://h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`; +} + +export function buildAgentDeepLinkRoute(target: AgentDeepLinkTarget): string { + const link = new URL(buildAgentDeepLink(target)); + return `/h${link.pathname}`; +} + +export function parseAgentDeepLink(input: string): AgentDeepLinkTarget | null { + let url: URL; + try { + url = new URL(input); + } catch { + return null; + } + + if ( + url.protocol !== "paseo:" || + url.hostname !== "h" || + url.username || + url.password || + url.port || + url.search || + url.hash + ) { + return null; + } + + const segments = url.pathname.split("/").filter(Boolean); + if (segments.length !== 3 || segments[1] !== "agent") { + return null; + } + + try { + const serverId = normalizeSegment(decodeURIComponent(segments[0] ?? "")); + const agentId = normalizeSegment(decodeURIComponent(segments[2] ?? "")); + return serverId && agentId ? { serverId, agentId } : null; + } catch { + return null; + } +}