Open existing agents from links and the CLI (#2324)

* feat(desktop): open existing agents from links

Register a stable agent deep link and route it through the existing Desktop window. Add a matching CLI command that resolves the local server and activates the requested agent without creating or messaging it.

* fix(desktop): recover agent link delivery
This commit is contained in:
Mohamed Boudra
2026-07-22 18:31:12 +02:00
committed by GitHub
parent 9952615c33
commit 76a5edb020
18 changed files with 504 additions and 42 deletions

View File

@@ -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("<agent-id>");
expect(open?.helpInformation()).toContain("--server <server-id>");
});
});

View File

@@ -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),
);

View File

@@ -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<OpenAgentResult> = {
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("<agent-id>", "Existing agent ID")
.option("--server <server-id>", "Server ID (defaults to the local daemon)");
}
async function resolveServerId(options: CommandOptions): Promise<string> {
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<SingleResult<OpenAgentResult>> {
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,
};
}

View File

@@ -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<void> {
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<void> {
launchDesktop([buildAgentDeepLink(target)]);
}