Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/fix-public-agent-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a public `prime-agent create` command for starting top-level agents with an initial message.
15 changes: 15 additions & 0 deletions packages/coding-agent/src/cli/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export const COMMAND_SPECS: readonly CommandSpec[] = [
summary: "List agents",
options: ["-a, --all Include saved agents", "--json Print JSON"],
},
{
path: ["create"],
usage: "create [options] [name] -- <message>",
summary: "Create and start an agent",
description: "Creates a top-level agent and starts it with the supplied message.",
options: [
"--cwd <dir> Use a specific working directory",
"--provider <name> Select a model provider",
"--model <id> Select a model",
"--thinking <level> Set the reasoning level",
"--tools <list> Allowlist comma-separated tool names",
"--daemon-socket <path> Use a specific daemon socket",
"--json Print JSON",
],
},
{
path: ["attach"],
usage: "attach <agent>",
Expand Down
26 changes: 23 additions & 3 deletions packages/coding-agent/src/cli/daemon-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ function parseDaemonClientCommand(args: string[]): ParsedDaemonClientCommand {
continue;
}

// send/cron parse "--" themselves as an end-of-flags separator
if (arg === "--" && (command === "cron" || command === "send")) {
// create/send/cron parse "--" themselves as an end-of-flags separator
if (arg === "--" && (command === "create" || command === "cron" || command === "send")) {
positionals.push(arg);
passthrough = true;
continue;
Expand Down Expand Up @@ -775,7 +775,19 @@ function parseListArgs(args: string[]): { all: boolean } {
}

async function runCreate(client: DaemonClient, args: string[], json: boolean): Promise<void> {
const sessionArgs = parseSessionArgs(args);
const separatorIndex = args.indexOf("--");
const sessionArgs = parseSessionArgs(separatorIndex === -1 ? args : args.slice(0, separatorIndex));
const initialMessage =
separatorIndex === -1
? undefined
: args
.slice(separatorIndex + 1)
.join(" ")
.trim();
if (separatorIndex !== -1 && !initialMessage) {
throw new Error("Initial message must not be empty");
}

const response = await client.request({
type: "create",
name: sessionArgs.name,
Expand All @@ -784,6 +796,14 @@ async function runCreate(client: DaemonClient, args: string[], json: boolean): P
continueRecent: sessionArgs.continueRecent,
});
const data = requireSuccess(response);
if (initialMessage) {
if (!isLiveSessionSummary(data)) {
throw new Error("Daemon returned an invalid create response");
}
await requireSuccessAsync(
client.request({ type: "prompt", activeSessionId: data.activeSessionId, message: initialMessage }),
);
}
if (json) {
printJson(data);
return;
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/cli/daemon-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ export function shouldStartDaemonEarly(args: readonly string[], startupBenchmark
(REMOVED_COMMAND_NAMES.has(firstPositional.value) ||
(PUBLIC_COMMAND_NAMES.has(firstPositional.value) &&
firstPositional.value !== "agents" &&
firstPositional.value !== "create" &&
(firstPositional.value !== "help" || isHelpCommand)))
) {
return false;
Expand Down
13 changes: 13 additions & 0 deletions packages/coding-agent/src/cli/public-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ async function runPublicCommand(args: string[]): Promise<PublicCommandResult> {
return { handled: false, args: args.slice(1), explicitAgentsView: true };
case "list":
return runInternalAgentCommand("list", args.slice(1));
case "create": {
const separatorIndex = args.indexOf("--", 1);
if (
separatorIndex === -1 ||
!args
.slice(separatorIndex + 1)
.join(" ")
.trim()
) {
return fail(`Usage: ${APP_NAME} ${getCommandSpec(["create"])!.usage}`);
}
return runInternalAgentCommand("create", args.slice(1));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cold create can fail to connect

High Severity

Public create only kicks off a fire-and-forget early daemon launch, then DaemonClient.connect runs immediately. That connect fails as soon as the socket is missing, so a cold prime-agent create can error while the background service is still starting. Interactive startup awaits ensureInteractiveDaemonRunning, and runOpen starts the daemon if needed; this path does neither.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 63f4875. Configure here.

}
case "attach": {
const rest = args.slice(1);
const agent = rest[0];
Expand Down
51 changes: 51 additions & 0 deletions packages/coding-agent/test/daemon-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const daemonClientMock = vi.hoisted(() => {
emitStaleAgentEndOnAttach: false,
connectFails: false,
sessions: [] as Array<Record<string, unknown>>,
createdSession: undefined as Record<string, unknown> | undefined,
};

class MockDaemonClient {
Expand All @@ -53,6 +54,9 @@ const daemonClientMock = vi.hoisted(() => {
if (command.type === "list") {
return { type: "response", command: command.type, success: true, data: { sessions: behavior.sessions } };
}
if (command.type === "create") {
return { type: "response", command: command.type, success: true, data: behavior.createdSession };
}
if (command.type === "attach" && behavior.emitStaleAgentEndOnAttach) {
this.emitMessage({ type: "session_event", activeSessionId: "active-1", event: { type: "agent_end" } });
}
Expand Down Expand Up @@ -138,6 +142,7 @@ describe("daemon command", () => {
daemonClientMock.behavior.emitStaleAgentEndOnAttach = false;
daemonClientMock.behavior.connectFails = false;
daemonClientMock.behavior.sessions = [];
daemonClientMock.behavior.createdSession = undefined;
consoleErrorMessages = [];
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null | undefined) => {
throw new Error(`exit ${code}`);
Expand Down Expand Up @@ -232,6 +237,52 @@ describe("daemon command", () => {
expect(client?.requests[1]?.name).not.toBe(unsafeIntegerName);
});

it("creates a session and submits an initial prompt after the separator", async () => {
daemonClientMock.behavior.promptSucceeds = true;
daemonClientMock.behavior.createdSession = makeSessionSummary("active-created", "session-created", "created");

await expect(
handleDaemonCommand([
"daemon",
"--socket",
"/tmp/prime-agent.sock",
"--json",
"create",
"--cwd",
"/tmp/project",
"my-session",
"--",
"--review",
"the fix",
]),
).resolves.toBe(true);

expect(daemonClientMock.instances[0]?.requests).toEqual([
{
type: "create",
name: "my-session",
config: { cwd: "/tmp/project" },
sessionPath: undefined,
continueRecent: undefined,
},
{ type: "prompt", activeSessionId: "active-created", message: "--review the fix" },
]);
expect(console.log).toHaveBeenCalledWith(JSON.stringify(daemonClientMock.behavior.createdSession, null, 2));
});

it("rejects an empty initial prompt before creating a session", async () => {
await expect(
handleDaemonCommand(["daemon", "--socket", "/tmp/prime-agent.sock", "create", "my-session", "--"]),
).resolves.toBe(true);

expect(daemonClientMock.instances[0]?.requests).toEqual([]);
expect(
consoleErrorMessages.some(
(message) => typeof message === "string" && message.includes("Initial message must not be empty"),
),
).toBe(true);
});

it("keeps create session name after an unknown boolean extension flag", async () => {
await expect(
handleDaemonCommand(["daemon", "--socket", "/tmp/prime-agent.sock", "create", "--unknown-typo", "my-session"]),
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/test/daemon-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ describe("shouldStartDaemonEarly", () => {
["json", ["--mode", "json", "hello"]],
["rpc", ["--mode", "rpc"]],
["no-session", ["--no-session"]],
["agent creation", ["--daemon-socket", "/tmp/prime.sock", "create", "reviewer", "--", "Review"]],
])("starts early for the %s client", (_label, args) => {
expect(shouldStartDaemonEarly(args, false)).toBe(true);
});
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/test/public-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,24 @@ describe("public command routing", () => {
expect(mocks.daemonCommands).toEqual([["daemon", "list", "--all", "--json"]]);
});

it("routes agent creation with an initial prompt through the internal protocol adapter", async () => {
await expect(
handlePublicCommand(["create", "--cwd", "/tmp/project", "reviewer", "--json", "--", "Review the fix"]),
).resolves.toMatchObject({ handled: true });
expect(mocks.daemonCommands).toEqual([
["daemon", "create", "--cwd", "/tmp/project", "reviewer", "--json", "--", "Review the fix"],
]);
});

it("rejects agent creation without an initial prompt", async () => {
await expect(handlePublicCommand(["create", "reviewer"])).resolves.toMatchObject({ handled: true });
await expect(handlePublicCommand(["create", "reviewer", "--"])).resolves.toMatchObject({ handled: true });

expect(mocks.daemonCommands).toEqual([]);
expect(process.exitCode).toBe(1);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("prime-agent create"));
});

it("forwards a custom daemon socket when stopping an agent", async () => {
await expect(
handlePublicCommand(["stop", "worker", "--daemon-socket", "/tmp/custom-daemon.sock"]),
Expand Down
Loading