Files
PaperClipAI/packages/adapters/cursor-cloud/src/server/execute.ts
T
DottaandPaperclip ebaeba40ee feat: simplify agent onboarding and configuration (#13011)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators create agents and configure their runtimes in the board
UI.
> - The old creation flow presents several choices and a large form
before an agent can start.
> - The existing onboarding controls already provide clear provider
connection steps.
> - This pull request uses those controls in a new-agent wizard and
organizes the full configuration pages.
> - Operators can connect, test, save, and assign a first task while
keeping the existing configuration tools.

## Linked Issues or Issue Description

Related: #10974. That earlier open PR also reorganizes agent
configuration. This PR follows the reviewed Storybook designs for agent
creation and the current configuration tabs.

**What existing behavior does this improve?**

Agent creation, provider connection, runtime tests, and full agent
configuration.

**Current behavior**

The creation dialog leads to a large manual configuration form. Provider
login controls differ from onboarding. Environment variables and secret
access appear in separate places.

**Proposed behavior**

Choose a name and adapter. Connect Claude or Codex through the existing
onboarding controls. Configure and test the runtime, save the agent, and
open a task dialog with that agent assigned. Use the same design on the
existing configuration tabs.

**Reason and benefit**

The first setup asks for fewer decisions. The full editor keeps
instructions, skills, runtime controls, secret access, permissions,
keys, and revisions available in clear sections.

**Breaking changes**

The board creation and configuration layouts change. The
test-environment API adds an optional, allowlisted `testCredentials`
field for one-shot probes. Database contracts stay the same. Native ACPX
tests now reject unsupported local platforms before a CLI login can mask
the runtime restriction.

## What Changed

- Added a new-agent wizard with numbered steps, adapter branding,
provider connections, editable model choices, runtime tests, and
confirmation.
- Added Codex app-server, Claude ACPX, and OpenCode runner choices.
- Stored API credentials through existing secret APIs and persisted
references in agent configuration. New setup keys are isolated from
credentials used by existing agents.
- Preserved external-agent invitations beside the wizard, including
optional messages, one-time prompts, and clipboard fallback.
- Added OpenRouter provider and secret bindings for Pi and OpenCode.
- Added adapter-specific prerequisite fields for Cursor, Gemini, Kimi,
and Hermes. Cursor Cloud keys are saved as new organization secrets.
- Fixed Cursor Cloud repository field mapping, omitted empty remote
environment values, and added useful model and repository error
messages.
- Preserved complete MCP assignments when multiple valid profiles
contain more than 250 tools in total. Generated profiles retain exact
tool selectors.
- Added service branding and deployment-aware adapter choices. Cloud
setup offers Claude, Codex, and OpenCode; local native runners require
the experimental setting.
- Made the agent list responsive at intermediate widths.
- Applied the reviewed design to the real agent configuration pages.
Kept the instruction editor, skills, and existing mutations.
- Combined secret access and environment variables under one Save and
Discard action.
- Added interactive Storybook screens for setup, configuration,
confirmation, authentication, and test results.
- Fixed Pi provider-error parsing and thinking-effort persistence.
Native ACPX validates Linux x64 on the actual local, SSH, or sandbox
target.
- Redacted the complete transient probe-credential field from HTTP error
logs, including rejected provider names.

## Verification

- Current head `df0292fe6` has a fresh Greptile 5/5 review with no
unresolved findings. All 31 executed CI checks passed, including the
aggregate verification gate and all browser E2E shards. Storybook visual
regression is skipped by its workflow; the local Storybook build passed.

- Browser tests completed real assigned tasks with direct Codex, Claude,
OpenCode, Pi, and native Codex.
- Verified external-agent invitation generation and automatic prompt
copying in the live browser.
- Pi and OpenCode used an existing OpenRouter secret. Browser checks
covered save and reload, instruction edits, skill selection,
environment-variable Save and Discard, and assigned task creation.
- Invalid Claude API credentials remained on the connection step with an
error. A live Pi/OpenRouter invalid-key probe returned a provider
failure and left the user-secret inventory unchanged (zero entries
before and after).
- Full workspace typecheck and build passed after rebasing onto current
master. After review fixes, server and UI typechecks, token gates, and
the full build passed again. Storybook built successfully.
- All 5,542 local UI tests passed. The Cursor Cloud and Pi adapter
regressions passed all 24 tests. Review regressions passed 69 server
tests and all 18 agent-list tests.
- The local full test command ran 6,971 general server tests
successfully. Editing review fixes during that long run caused nine
tests to use stale modules; fresh isolated runs passed. An unrelated
embedded-Postgres fixture hit the host shared-memory limit; its 15
affected tests passed when the fixture groups ran separately.
- Local workspace groups passed after rerunning 18 CLI tests
sequentially to avoid host database limits and parallel-load timeouts.
The local full command stopped at the general server phase, so
serialized server verification comes from the five passing CI shards.
- Browser testing at 390px confirmed that the agent action menu opens
and the page has no horizontal overflow. CI browser E2E shards passed.
- Review the `Onboarding / New agent` and `Agents / Configuration
refresh` Storybook groups. In the real app, create an agent, run its
connection test, save it, assign a task, and reload its configuration.

## Risks

- This changes the main agent setup and configuration UI. Regression
tests cover routing, persistence, secret bindings, and form actions.
- Native Claude ACPX requires Linux x64. Direct Claude works on macOS.
Remote checks execute a bounded platform probe and reject unsupported or
unverified targets.
- A native OpenCode task reached the provider context limit because of
its tool payload. Its provider connection test passed. Direct OpenCode
completed a task. This existing native execution limit is not fixed
here.
- Claude and Codex connection keys use the existing user-secret store.
Other runtime setup keys use distinct organization secrets. Existing
credentials are never rotated. Probes do not store entered keys. Failed
agent creation removes newly staged credentials.
- Cursor Cloud has not completed a live task. Its authenticated account
still needs GitHub repository access. The live run passed MCP
provisioning, remote environment validation, and explicit Auto model
selection before the repository prerequisite blocked execution.
- Generated runtime MCP profiles can exceed the public profile-edit
request limit. They still contain exact catalog selectors and preserve
permission boundaries.
- No database migration, dependency, lockfile, or workflow changes are
included.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, shell
execution, and browser automation. The runtime did not expose the exact
model ID or context window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-08 14:22:32 -05:00

640 lines
22 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import {
Agent,
type AgentOptions,
type ModelSelection,
type Run,
type RunResult,
type SDKAgent,
type SDKMessage,
} from "@cursor/sdk";
import type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta } from "@paperclipai/adapter-utils";
import {
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
asBoolean,
asString,
buildPaperclipEnv,
buildRuntimeToolsEnv,
joinPromptSections,
parseObject,
readPaperclipIssueWorkModeFromContext,
renderPaperclipWakePrompt,
isPaperclipRecoveryWakePayload,
renderTemplate,
stringifyPaperclipWakePayload,
} from "@paperclipai/adapter-utils/server-utils";
type CursorCloudSession = {
cursorAgentId: string;
latestRunId?: string;
runtime: "cloud";
envType?: "cloud" | "pool" | "machine";
envName?: string;
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>;
};
type CursorCloudEvent =
| { type: "cursor_cloud.init"; sessionId: string; agentId: string; runId?: string; model?: string }
| { type: "cursor_cloud.status"; status: string; message?: string }
| { type: "cursor_cloud.message"; message: SDKMessage }
| {
type: "cursor_cloud.result";
status: string;
result?: string;
model?: string;
durationMs?: number;
git?: unknown;
error?: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asStringEnvMap(value: unknown): Record<string, string> {
const parsed = parseObject(value);
const env: Record<string, string> = {};
for (const [key, entry] of Object.entries(parsed)) {
if (typeof entry === "string") {
env[key] = entry;
} else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) {
const rec = entry as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") env[key] = rec.value;
}
}
return env;
}
function normalizeEnvType(raw: string): "cloud" | "pool" | "machine" {
const value = raw.trim().toLowerCase();
if (value === "pool" || value === "machine") return value;
return "cloud";
}
function trimNullable(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function toModelSelection(rawModel: string): ModelSelection | undefined {
const model = rawModel.trim();
return model ? { id: model } : undefined;
}
function toSummary(result: RunResult): string | null {
const direct = trimNullable(result.result);
if (direct) return firstNonEmptyLine(direct);
return null;
}
function formatRunError(err: unknown): string {
if (err instanceof Error && err.message.trim().length > 0) return err.message.trim();
return String(err);
}
function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, string>): Record<string, string> {
const { runId, agent, context, authToken } = ctx;
const env: Record<string, string> = {
...configEnv,
...buildPaperclipEnv(agent),
...buildRuntimeToolsEnv(ctx.runtimeTools),
PAPERCLIP_RUN_ID: runId,
};
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
// token is the only source of Paperclip API identity.
delete env.PAPERCLIP_API_KEY;
const wakeTaskId = trimNullable(context.taskId) ?? trimNullable(context.issueId);
const wakeReason = trimNullable(context.wakeReason);
const wakeCommentId = trimNullable(context.wakeCommentId) ?? trimNullable(context.commentId);
const approvalId = trimNullable(context.approvalId);
const approvalStatus = trimNullable(context.approvalStatus);
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
// cursor_cloud runs remotely in Cursor's cloud and is intentionally not
// issued a Paperclip run JWT (registry: supportsLocalAgentJwt=false).
// buildPaperclipEnv always sets PAPERCLIP_API_URL, defaulting to the local
// runtime host — which a remote worker can neither reach nor authenticate
// against, so any agent-initiated Paperclip API call would fail with a 401
// (or be unreachable) and add noise. When there is no usable key, drop the
// callback wiring so cloud-side Paperclip tools degrade to a clean no-op.
// Run results are delivered server-side via the Cursor Agent SDK (getRun /
// wait), not through this callback, so nothing is lost.
if (!trimNullable(env.PAPERCLIP_API_KEY)) {
delete env.PAPERCLIP_API_URL;
delete env.PAPERCLIP_API_BRIDGE_MODE;
}
const workspace = parseObject(context.paperclipWorkspace);
const workspaceMappings: Array<[string, unknown]> = [
["PAPERCLIP_WORKSPACE_CWD", workspace.cwd],
["PAPERCLIP_WORKSPACE_SOURCE", workspace.source],
["PAPERCLIP_WORKSPACE_ID", workspace.workspaceId],
["PAPERCLIP_WORKSPACE_REPO_URL", workspace.repoUrl],
["PAPERCLIP_WORKSPACE_REPO_REF", workspace.repoRef],
["PAPERCLIP_WORKSPACE_BRANCH", workspace.branch],
["PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspace.worktreePath],
["AGENT_HOME", workspace.agentHome],
];
for (const [key, value] of workspaceMappings) {
const normalized = trimNullable(value);
if (normalized) env[key] = normalized;
}
delete env.CURSOR_API_KEY;
// Cursor rejects the entire request when any envVars value is empty.
// Paperclip may use empty values to unset optional host credentials; remote
// workers do not inherit those host variables, so omit the empty entries.
return Object.fromEntries(Object.entries(env).filter(([, value]) => value.length > 0));
}
async function buildInstructionsPrefix(
config: Record<string, unknown>,
onLog: AdapterExecutionContext["onLog"],
): Promise<{ prefix: string; notes: string[]; chars: number }> {
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
if (!instructionsFilePath) {
return { prefix: "", notes: [], chars: 0 };
}
try {
const contents = await fs.readFile(instructionsFilePath, "utf8");
const instructionsDir = `${path.dirname(instructionsFilePath)}/`;
const prefix = `${contents.trim()}\n\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsDir}.\n`;
return {
prefix,
chars: prefix.length,
notes: [
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
],
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stderr",
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
);
return {
prefix: "",
chars: 0,
notes: [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
],
};
}
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const keys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (keys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in the cloud agent shell: ${keys.join(", ")}`,
"Use them directly instead of assuming they are absent.",
].join("\n");
}
function readSession(params: Record<string, unknown> | null): CursorCloudSession | null {
if (!params) return null;
const record = asRecord(params);
if (!record) return null;
const cursorAgentId =
trimNullable(record.cursorAgentId) ??
trimNullable(record.agentId) ??
trimNullable(record.sessionId);
if (!cursorAgentId) return null;
const latestRunId = trimNullable(record.latestRunId) ?? trimNullable(record.runId) ?? undefined;
const envType = trimNullable(record.envType);
const envName = trimNullable(record.envName);
const reposValue = Array.isArray(record.repos) ? record.repos : [];
const repos = reposValue
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => Boolean(entry))
.map((entry) => ({
url: asString(entry.url, "").trim(),
startingRef: trimNullable(entry.startingRef) ?? undefined,
prUrl: trimNullable(entry.prUrl) ?? undefined,
}))
.filter((entry) => entry.url.length > 0);
return {
cursorAgentId,
...(latestRunId ? { latestRunId } : {}),
runtime: "cloud",
...(envType ? { envType: normalizeEnvType(envType) } : {}),
...(envName ? { envName } : {}),
repos,
};
}
function sessionMatches(
session: CursorCloudSession | null,
envType: "cloud" | "pool" | "machine",
envName: string | null,
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>,
): boolean {
if (!session) return false;
if ((session.envType ?? "cloud") !== envType) return false;
if ((session.envName ?? null) !== envName) return false;
if (session.repos.length !== repos.length) return false;
return session.repos.every((repo, index) => {
const next = repos[index];
return repo.url === next.url
&& (repo.startingRef ?? null) === (next.startingRef ?? null)
&& (repo.prUrl ?? null) === (next.prUrl ?? null);
});
}
function buildAgentOptions(input: {
apiKey: string;
name: string;
model?: ModelSelection;
envType: "cloud" | "pool" | "machine";
envName: string | null;
repos: Array<{ url: string; startingRef?: string; prUrl?: string }>;
workOnCurrentBranch: boolean;
autoCreatePR: boolean;
skipReviewerRequest: boolean;
envVars: Record<string, string>;
}): AgentOptions {
return {
apiKey: input.apiKey,
name: input.name,
...(input.model ? { model: input.model } : {}),
cloud: {
env: {
type: input.envType,
...(input.envName ? { name: input.envName } : {}),
},
repos: input.repos,
workOnCurrentBranch: input.workOnCurrentBranch,
autoCreatePR: input.autoCreatePR,
skipReviewerRequest: input.skipReviewerRequest,
envVars: input.envVars,
},
};
}
function eventLine(event: CursorCloudEvent): string {
return `${JSON.stringify(event)}\n`;
}
async function emitMessage(onLog: AdapterExecutionContext["onLog"], message: SDKMessage) {
await onLog("stdout", eventLine({ type: "cursor_cloud.message", message }));
}
async function emitStatus(onLog: AdapterExecutionContext["onLog"], status: string, message?: string) {
await onLog("stdout", eventLine({ type: "cursor_cloud.status", status, ...(message ? { message } : {}) }));
}
async function streamRun(run: Run, onLog: AdapterExecutionContext["onLog"]) {
if (!run.supports("stream")) return;
for await (const message of run.stream()) {
await emitMessage(onLog, message);
}
}
async function getAttachedRun(input: {
apiKey: string;
session: CursorCloudSession | null;
}): Promise<Run | null> {
const latestRunId = input.session?.latestRunId;
const cursorAgentId = input.session?.cursorAgentId;
if (!latestRunId || !cursorAgentId) return null;
try {
const run = await Agent.getRun(latestRunId, {
runtime: "cloud",
agentId: cursorAgentId,
apiKey: input.apiKey,
});
return run.status === "running" ? run : null;
} catch {
return null;
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, onDispatch } = ctx;
const envConfig = asStringEnvMap(config.env);
const apiKey = asString(envConfig.CURSOR_API_KEY, "").trim();
if (!apiKey) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "CURSOR_API_KEY is required for cursor_cloud.",
provider: "cursor",
biller: "cursor",
billingType: "api",
clearSession: false,
};
}
const workspace = parseObject(context.paperclipWorkspace);
const repoUrl =
asString(config.repoUrl, "").trim() ||
asString(workspace.repoUrl, "").trim();
if (!repoUrl) {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: "cursor_cloud requires repoUrl in adapterConfig or workspace context.",
provider: "cursor",
biller: "cursor",
billingType: "api",
clearSession: false,
};
}
const repoStartingRef =
trimNullable(config.repoStartingRef) ??
trimNullable(workspace.repoRef) ??
undefined;
const repoPullRequestUrl = trimNullable(config.repoPullRequestUrl) ?? undefined;
const envType = normalizeEnvType(asString(config.runtimeEnvType, "cloud"));
const envName = trimNullable(config.runtimeEnvName);
const workOnCurrentBranch = asBoolean(config.workOnCurrentBranch, false);
const autoCreatePR = asBoolean(config.autoCreatePR, false);
const skipReviewerRequest = asBoolean(config.skipReviewerRequest, false);
const model = toModelSelection(asString(config.model, ""));
const repos = [{
url: repoUrl,
...(repoStartingRef ? { startingRef: repoStartingRef } : {}),
...(repoPullRequestUrl ? { prUrl: repoPullRequestUrl } : {}),
}];
const remoteEnv = buildWakeEnv(ctx, envConfig);
const session = readSession(runtime.sessionParams) ?? (runtime.sessionId
? {
cursorAgentId: runtime.sessionId,
runtime: "cloud" as const,
repos,
}
: null);
const canReuseSession = sessionMatches(session, envType, envName, repos);
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const instructions = await buildInstructionsPrefix(config, onLog);
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canReuseSession });
const renderedBootstrapPrompt =
!canReuseSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const renderedPrompt =
(canReuseSession && wakePrompt.length > 0) || isPaperclipRecoveryWakePayload(context.paperclipWake)
? ""
: renderTemplate(promptTemplate, templateData).trim();
const paperclipEnvNote = renderPaperclipEnvNote(remoteEnv);
const prompt = joinPromptSections([
instructions.prefix,
renderedBootstrapPrompt,
wakePrompt,
paperclipEnvNote,
renderedPrompt,
]);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const finalPrompt = joinPromptSections([prompt, sessionHandoffNote]);
const agentOptions = buildAgentOptions({
apiKey,
name: `Paperclip ${agent.name}`,
model,
envType,
envName,
repos,
workOnCurrentBranch,
autoCreatePR,
skipReviewerRequest,
envVars: remoteEnv,
});
const commandNotes = [
...instructions.notes,
canReuseSession
? `Reusing Cursor cloud agent session ${session?.cursorAgentId ?? "unknown"}`
: "Creating a new Cursor cloud agent session",
`Repository: ${repoUrl}${repoStartingRef ? ` @ ${repoStartingRef}` : ""}`,
`Runtime target: ${envType}${envName ? ` (${envName})` : ""}`,
];
if (onMeta) {
const meta: AdapterInvocationMeta = {
adapterType: "cursor_cloud",
command: "@cursor/sdk",
commandNotes,
prompt: finalPrompt,
promptMetrics: {
promptChars: finalPrompt.length,
instructionsChars: instructions.chars,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
heartbeatPromptChars: renderedPrompt.length,
},
context: {
cursorCloud: {
envType,
envName,
repoUrl,
repoStartingRef,
repoPullRequestUrl,
canReuseSession,
},
},
};
await onMeta(meta);
}
let sdkAgent: SDKAgent | null = null;
let run: Run | null = null;
let streamError: string | null = null;
try {
// This adapter has no local child process, so crossing into the first SDK
// request is its dispatch boundary. Report it before any potentially
// long-running remote reattach/create/send operation.
onDispatch?.();
const attachedRun = canReuseSession
? await getAttachedRun({ apiKey, session })
: null;
if (attachedRun) {
await emitStatus(onLog, "running", `Reattached to existing Cursor run ${attachedRun.id}.`);
await onLog("stdout", eventLine({
type: "cursor_cloud.init",
sessionId: attachedRun.agentId,
agentId: attachedRun.agentId,
runId: attachedRun.id,
...(model?.id ? { model: model.id } : {}),
}));
const priorStreamPromise = streamRun(attachedRun, onLog).catch((err) => {
streamError = formatRunError(err);
});
if (attachedRun.supports("wait")) await attachedRun.wait();
await priorStreamPromise;
streamError = null;
await emitStatus(
onLog,
"running",
`Prior Cursor run ${attachedRun.id} finished; sending heartbeat follow-up so this wake's context is not dropped.`,
);
}
sdkAgent = canReuseSession && session
? await Agent.resume(session.cursorAgentId, agentOptions)
: await Agent.create(agentOptions);
run = await sdkAgent.send(finalPrompt, {
...(model ? { model } : {}),
});
await onLog("stdout", eventLine({
type: "cursor_cloud.init",
sessionId: sdkAgent.agentId,
agentId: sdkAgent.agentId,
runId: run.id,
...(model?.id ? { model: model.id } : {}),
}));
await emitStatus(onLog, "running", `Started Cursor run ${run.id}.`);
const streamPromise = streamRun(run, onLog).catch((err) => {
streamError = formatRunError(err);
});
const result = run.supports("wait")
? await run.wait()
: {
id: run.id,
status: run.status === "running" ? "error" : run.status,
result: run.result,
model: run.model,
durationMs: run.durationMs,
git: run.git,
};
await streamPromise;
const modelId = result.model?.id ?? model?.id ?? null;
await onLog("stdout", eventLine({
type: "cursor_cloud.result",
status: result.status,
...(result.result ? { result: result.result } : {}),
...(modelId ? { model: modelId } : {}),
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
...(result.git ? { git: result.git } : {}),
...(streamError ? { error: streamError } : {}),
}));
const nextSession: CursorCloudSession = {
cursorAgentId: run.agentId,
latestRunId: result.id,
runtime: "cloud",
envType,
...(envName ? { envName } : {}),
repos,
};
const isError = result.status !== "finished";
return {
exitCode: isError ? 1 : 0,
signal: null,
timedOut: false,
errorMessage: isError ? (trimNullable(result.result) ?? streamError ?? `Cursor run ${result.status}`) : null,
sessionId: run.agentId,
sessionDisplayId: run.agentId,
sessionParams: nextSession,
provider: "cursor",
biller: "cursor",
billingType: "api",
model: modelId,
costUsd: null,
summary: toSummary(result),
resultJson: {
status: result.status,
cursorAgentId: run.agentId,
cursorRunId: result.id,
envType,
envName,
repos,
...(result.result ? { result: result.result } : {}),
...(result.git ? { git: result.git } : {}),
...(typeof result.durationMs === "number" ? { durationMs: result.durationMs } : {}),
...(streamError ? { streamError } : {}),
},
clearSession: false,
};
} catch (err) {
const error = formatRunError(err);
const reason = !model && error.includes("[invalid_model]")
? `${error} Cursor rejected its configured default model. Choose an available default at https://cursor.com/dashboard/cloud-agents or set this agent's model explicitly.`
: error.includes("Failed to determine repository default branch")
? `${error} Verify that Cursor's GitHub integration can access ${repoUrl} at https://cursor.com/dashboard/cloud-agents. If the repository has no default branch, configure a starting branch for this agent.`
: error;
if (run) {
await onLog("stdout", eventLine({
type: "cursor_cloud.result",
status: "error",
error: reason,
}));
}
return {
exitCode: 1,
signal: null,
timedOut: false,
errorMessage: reason,
sessionId: session?.cursorAgentId ?? null,
sessionDisplayId: session?.cursorAgentId ?? null,
sessionParams: session,
provider: "cursor",
biller: "cursor",
billingType: "api",
costUsd: null,
clearSession: false,
resultJson: {
status: "error",
...(run ? { cursorRunId: run.id } : {}),
...(session?.cursorAgentId ? { cursorAgentId: session.cursorAgentId } : {}),
error: reason,
},
};
} finally {
if (sdkAgent) {
try {
await sdkAgent[Symbol.asyncDispose]();
} catch {
// Best effort only.
}
}
}
}