mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - When an agent runs against a remote target, Paperclip syncs the
workspace out to the remote at run start and restores changes back to
the local workspace at run end
> - The previous restore flow naïvely overwrote local files with
whatever the remote returned, so files that the remote run never touched
but had timestamp/mode drift could be needlessly rewritten — and a
single static `refs/paperclip/ssh-sync/imported` ref made concurrent SSH
workspace exports race on the same git ref
> - This pull request adds a `workspace-restore-merge` module that diffs
a pre-run snapshot against the post-run remote state and only writes
back files the remote actually changed; SSH workspace exports now use a
per-import unique ref so concurrent runs can't trample each other
> - Every adapter's execute path threads the snapshot through
`prepareAdapterExecutionTargetRuntime` so the merge has the baseline it
needs
> - The benefit is workspace restores no longer churn untouched files,
and concurrent SSH runs no longer collide on the import ref
## What Changed
- `packages/adapter-utils/src/workspace-restore-merge.{ts,test.ts}`: new
module — directory snapshot (kind/mode/sha256/symlink target) plus
snapshot-aware merge that writes only the files the remote changed
- `packages/adapter-utils/src/ssh.ts`: SSH workspace export uses a
per-import unique ref (`refs/paperclip/ssh-sync/imported/<uuid>`);
restore goes through the new merge helper; `ssh-fixture.test.ts` covers
the unique-ref + merge paths
- `packages/adapter-utils/src/sandbox-managed-runtime.ts` +
`remote-managed-runtime.ts`: thread the snapshot/merge through the
sandbox and SSH paths
- `packages/adapter-utils/src/server-utils.{ts,test.ts}` +
`execution-target.ts`: helpers for capturing the pre-run snapshot;
`prepareAdapterExecutionTargetRuntime` gains required `runId` and
optional `workspaceRemoteDir`, and returns the realized
`workspaceRemoteDir`
- Each adapter's `execute.ts` (acpx, claude, codex, cursor, gemini,
opencode, pi) takes the snapshot at run start and passes it through to
the runtime restore
- Remote execute test mocks updated to match the new
`prepareWorkspaceForSshExecution` return shape and the per-run
`${managedRemoteWorkspace}` cwd subdirectory
## Verification
- `pnpm vitest run --no-coverage --project @paperclipai/adapter-utils
--project @paperclipai/adapter-acpx-local --project
@paperclipai/adapter-claude-local --project
@paperclipai/adapter-codex-local --project
@paperclipai/adapter-cursor-local --project
@paperclipai/adapter-gemini-local --project
@paperclipai/adapter-opencode-local --project
@paperclipai/adapter-pi-local` — 196/196 passing
- `pnpm typecheck` clean across the workspace
## Risks
Medium. The restore path now writes a strict subset of what it
previously did — files the remote did not touch are no longer rewritten.
If any flow was relying on a touch-without-content-change being copied
back (timestamp or permission propagation only), that behavior is now
skipped. Snapshot capture adds an O(N-files-in-workspace) hash pass at
run start; the cost is bounded by the existing exclude list. The `runId`
parameter on `prepareAdapterExecutionTargetRuntime` is now required —
every in-tree caller is updated; out-of-tree adapter authors need to
pass it.
## Model Used
Claude Opus 4.7 (1M context)
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable — new module +
every adapter execute path covered
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (no UI)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
719 lines
27 KiB
TypeScript
719 lines
27 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import {
|
|
adapterExecutionTargetIsRemote,
|
|
adapterExecutionTargetRemoteCwd,
|
|
adapterExecutionTargetSessionIdentity,
|
|
adapterExecutionTargetSessionMatches,
|
|
adapterExecutionTargetUsesManagedHome,
|
|
adapterExecutionTargetUsesPaperclipBridge,
|
|
describeAdapterExecutionTarget,
|
|
ensureAdapterExecutionTargetCommandResolvable,
|
|
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
|
prepareAdapterExecutionTargetRuntime,
|
|
readAdapterExecutionTarget,
|
|
readAdapterExecutionTargetHomeDir,
|
|
resolveAdapterExecutionTargetCommandForLogs,
|
|
runAdapterExecutionTargetProcess,
|
|
runAdapterExecutionTargetShellCommand,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asStringArray,
|
|
parseObject,
|
|
applyPaperclipWorkspaceEnv,
|
|
buildPaperclipEnv,
|
|
buildInvocationEnvForLogs,
|
|
ensureAbsoluteDirectory,
|
|
ensurePaperclipSkillSymlink,
|
|
ensurePathInEnv,
|
|
readPaperclipRuntimeSkillEntries,
|
|
readPaperclipIssueWorkModeFromContext,
|
|
resolvePaperclipDesiredSkillNames,
|
|
removeMaintainerOnlySkillSymlinks,
|
|
renderTemplate,
|
|
renderPaperclipWakePrompt,
|
|
shapePaperclipWorkspaceEnvForExecution,
|
|
stringifyPaperclipWakePayload,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
joinPromptSections,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { DEFAULT_CURSOR_LOCAL_MODEL, SANDBOX_INSTALL_COMMAND } from "../index.js";
|
|
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
|
import { prepareCursorSandboxCommand } from "./remote-command.js";
|
|
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
|
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
function firstNonEmptyLine(text: string): string {
|
|
return (
|
|
text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean) ?? ""
|
|
);
|
|
}
|
|
|
|
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
|
const raw = env[key];
|
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
}
|
|
|
|
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
|
|
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
|
|
? "api"
|
|
: "subscription";
|
|
}
|
|
|
|
function resolveCursorBiller(
|
|
env: Record<string, string>,
|
|
billingType: "api" | "subscription",
|
|
provider: string | null,
|
|
): string {
|
|
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, null);
|
|
if (openAiCompatibleBiller === "openrouter") return "openrouter";
|
|
if (billingType === "subscription") return "cursor";
|
|
return provider ?? "cursor";
|
|
}
|
|
|
|
function resolveProviderFromModel(model: string): string | null {
|
|
const trimmed = model.trim().toLowerCase();
|
|
if (!trimmed) return null;
|
|
const slash = trimmed.indexOf("/");
|
|
if (slash > 0) return trimmed.slice(0, slash);
|
|
if (trimmed.includes("sonnet") || trimmed.includes("claude")) return "anthropic";
|
|
if (trimmed.startsWith("gpt") || trimmed.startsWith("o")) return "openai";
|
|
return null;
|
|
}
|
|
|
|
function normalizeMode(rawMode: string): "plan" | "ask" | null {
|
|
const mode = rawMode.trim().toLowerCase();
|
|
if (mode === "plan" || mode === "ask") return mode;
|
|
return null;
|
|
}
|
|
|
|
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
|
const paperclipKeys = Object.keys(env)
|
|
.filter((key) => key.startsWith("PAPERCLIP_"))
|
|
.sort();
|
|
if (paperclipKeys.length === 0) return "";
|
|
return [
|
|
"Paperclip runtime note:",
|
|
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
|
"Do not assume these variables are missing without checking your shell environment.",
|
|
"",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function cursorSkillsHome(): string {
|
|
return path.join(os.homedir(), ".cursor", "skills");
|
|
}
|
|
|
|
async function buildCursorSkillsDir(config: Record<string, unknown>): Promise<string> {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-skills-"));
|
|
const target = path.join(tmp, "skills");
|
|
await fs.mkdir(target, { recursive: true });
|
|
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredNames = new Set(resolvePaperclipDesiredSkillNames(config, availableEntries));
|
|
for (const entry of availableEntries) {
|
|
if (!desiredNames.has(entry.key)) continue;
|
|
await fs.symlink(entry.source, path.join(target, entry.runtimeName));
|
|
}
|
|
return target;
|
|
}
|
|
|
|
type EnsureCursorSkillsInjectedOptions = {
|
|
skillsDir?: string | null;
|
|
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
|
skillsHome?: string;
|
|
linkSkill?: (source: string, target: string) => Promise<void>;
|
|
};
|
|
|
|
export async function ensureCursorSkillsInjected(
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
options: EnsureCursorSkillsInjectedOptions = {},
|
|
) {
|
|
const skillsEntries = options.skillsEntries
|
|
?? (options.skillsDir
|
|
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => ({
|
|
key: entry.name,
|
|
runtimeName: entry.name,
|
|
source: path.join(options.skillsDir!, entry.name),
|
|
}))
|
|
: await readPaperclipRuntimeSkillEntries({}, __moduleDir));
|
|
if (skillsEntries.length === 0) return;
|
|
|
|
const skillsHome = options.skillsHome ?? cursorSkillsHome();
|
|
try {
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
} catch (err) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to prepare Cursor skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
return;
|
|
}
|
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
|
skillsHome,
|
|
skillsEntries.map((entry) => entry.runtimeName),
|
|
);
|
|
for (const skillName of removedSkills) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`,
|
|
);
|
|
}
|
|
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
|
|
for (const entry of skillsEntries) {
|
|
const target = path.join(skillsHome, entry.runtimeName);
|
|
try {
|
|
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
|
if (result === "skipped") continue;
|
|
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.key}" into ${skillsHome}\n`,
|
|
);
|
|
} catch (err) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to inject Cursor skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
|
const executionTarget = readAdapterExecutionTarget({
|
|
executionTarget: ctx.executionTarget,
|
|
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
|
|
});
|
|
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
|
|
|
const promptTemplate = asString(
|
|
config.promptTemplate,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
);
|
|
let command = asString(config.command, "agent");
|
|
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
|
const mode = normalizeMode(asString(config.mode, ""));
|
|
|
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
|
const workspaceSource = asString(workspaceContext.source, "");
|
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
|
const agentHome = asString(workspaceContext.agentHome, "");
|
|
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
|
? context.paperclipWorkspaces.filter(
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
)
|
|
: [];
|
|
const configuredCwd = asString(config.cwd, "");
|
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
|
|
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
workspaceHints,
|
|
executionTargetIsRemote,
|
|
executionCwd: effectiveExecutionCwd,
|
|
});
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
|
if (!executionTargetIsRemote) {
|
|
await ensureCursorSkillsInjected(onLog, {
|
|
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)),
|
|
});
|
|
}
|
|
|
|
const envConfig = parseObject(config.env);
|
|
const hasExplicitApiKey =
|
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
|
let env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
|
env.PAPERCLIP_RUN_ID = runId;
|
|
const wakeTaskId =
|
|
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
|
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
|
null;
|
|
const wakeReason =
|
|
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
|
? context.wakeReason.trim()
|
|
: null;
|
|
const wakeCommentId =
|
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
|
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
|
null;
|
|
const approvalId =
|
|
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
|
? context.approvalId.trim()
|
|
: null;
|
|
const approvalStatus =
|
|
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
|
? context.approvalStatus.trim()
|
|
: null;
|
|
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 (issueWorkMode) {
|
|
env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
|
}
|
|
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;
|
|
}
|
|
applyPaperclipWorkspaceEnv(env, {
|
|
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
|
workspaceSource,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
agentHome,
|
|
});
|
|
if (shapedWorkspaceEnv.workspaceHints.length > 0) {
|
|
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints);
|
|
}
|
|
for (const [k, v] of Object.entries(envConfig)) {
|
|
if (typeof v === "string") env[k] = v;
|
|
}
|
|
if (!hasExplicitApiKey && authToken) {
|
|
env.PAPERCLIP_API_KEY = authToken;
|
|
}
|
|
const timeoutSec = asNumber(config.timeoutSec, 0);
|
|
const graceSec = asNumber(config.graceSec, 20);
|
|
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
|
|
runId,
|
|
target: executionTarget,
|
|
installCommand: ctx.runtimeCommandSpec?.installCommand,
|
|
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
// Probe the sandbox before the managed-home override so we discover
|
|
// cursor-agent from the real system HOME (e.g. ~/.local/bin/cursor-agent).
|
|
// The managed HOME set later is for runtime isolation, not for finding the CLI.
|
|
const sandboxCommand = await prepareCursorSandboxCommand({
|
|
runId,
|
|
target: executionTarget,
|
|
command,
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
});
|
|
command = sandboxCommand.command;
|
|
env = sandboxCommand.env;
|
|
const effectiveEnv = Object.fromEntries(
|
|
Object.entries({ ...process.env, ...env }).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
);
|
|
const billingType = resolveCursorBillingType(effectiveEnv);
|
|
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
|
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { installCommand: SANDBOX_INSTALL_COMMAND });
|
|
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
|
|
let loggedEnv = buildInvocationEnvForLogs(env, {
|
|
runtimeEnv,
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand,
|
|
});
|
|
|
|
const extraArgs = (() => {
|
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
return asStringArray(config.args);
|
|
})();
|
|
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
|
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
|
|
let localSkillsDir: string | null = null;
|
|
let remoteRuntimeRootDir: string | null = null;
|
|
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
|
|
|
if (executionTargetIsRemote) {
|
|
try {
|
|
localSkillsDir = await buildCursorSkillsDir(config);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Syncing workspace and Cursor runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
|
);
|
|
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
|
|
runId,
|
|
target: executionTarget,
|
|
adapterKey: "cursor",
|
|
workspaceLocalDir: cwd,
|
|
installCommand: SANDBOX_INSTALL_COMMAND,
|
|
detectCommand: command,
|
|
assets: [{
|
|
key: "skills",
|
|
localDir: localSkillsDir,
|
|
followSymlinks: true,
|
|
}],
|
|
});
|
|
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
|
|
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
|
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
|
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
|
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
|
|
}
|
|
const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir
|
|
? preparedExecutionTargetRuntime.runtimeRootDir
|
|
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) {
|
|
const remoteSkillsDir = path.posix.join(remoteHomeDir, ".cursor", "skills");
|
|
await runAdapterExecutionTargetShellCommand(
|
|
runId,
|
|
executionTarget,
|
|
`mkdir -p ${JSON.stringify(path.posix.dirname(remoteSkillsDir))} && rm -rf ${JSON.stringify(remoteSkillsDir)} && cp -a ${JSON.stringify(preparedExecutionTargetRuntime.assetDirs.skills)} ${JSON.stringify(remoteSkillsDir)}`,
|
|
{ cwd, env, timeoutSec, graceSec, onLog },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
await Promise.allSettled([
|
|
restoreRemoteWorkspace?.(),
|
|
localSkillsDir ? fs.rm(localSkillsDir, { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
|
]);
|
|
throw error;
|
|
}
|
|
}
|
|
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
|
|
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId,
|
|
target: executionTarget,
|
|
runtimeRootDir: remoteRuntimeRootDir,
|
|
adapterKey: "cursor",
|
|
hostApiToken: env.PAPERCLIP_API_KEY,
|
|
onLog,
|
|
});
|
|
if (paperclipBridge) {
|
|
Object.assign(env, paperclipBridge.env);
|
|
loggedEnv = buildInvocationEnvForLogs(env, {
|
|
runtimeEnv: ensurePathInEnv({ ...process.env, ...env }),
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand,
|
|
});
|
|
}
|
|
}
|
|
|
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
|
|
const canResumeSession =
|
|
runtimeSessionId.length > 0 &&
|
|
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
|
|
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
|
|
const sessionId = canResumeSession ? runtimeSessionId : null;
|
|
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Cursor session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
|
|
);
|
|
} else if (runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
|
|
);
|
|
}
|
|
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
let instructionsPrefix = "";
|
|
let instructionsChars = 0;
|
|
if (instructionsFilePath) {
|
|
try {
|
|
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
|
instructionsPrefix =
|
|
`${instructionsContents}\n\n` +
|
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
instructionsChars = instructionsPrefix.length;
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
const commandNotes = (() => {
|
|
const notes: string[] = [];
|
|
if (autoTrustEnabled) {
|
|
notes.push("Auto-added --yolo to bypass interactive prompts.");
|
|
}
|
|
notes.push("Prompt is piped to Cursor via stdin.");
|
|
if (sandboxCommand.addedPathEntry) {
|
|
notes.push(`Remote sandbox runs prepend ${sandboxCommand.addedPathEntry} to PATH.`);
|
|
}
|
|
if (sandboxCommand.preferredCommandPath) {
|
|
notes.push(`Remote sandbox runs prefer ${sandboxCommand.preferredCommandPath} when using the default Cursor entrypoint.`);
|
|
}
|
|
if (!instructionsFilePath) return notes;
|
|
if (instructionsPrefix.length > 0) {
|
|
notes.push(
|
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
|
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
|
);
|
|
return notes;
|
|
}
|
|
notes.push(
|
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
);
|
|
return notes;
|
|
})();
|
|
|
|
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 renderedBootstrapPrompt =
|
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
: "";
|
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
|
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
|
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
|
const prompt = joinPromptSections([
|
|
instructionsPrefix,
|
|
renderedBootstrapPrompt,
|
|
wakePrompt,
|
|
sessionHandoffNote,
|
|
paperclipEnvNote,
|
|
renderedPrompt,
|
|
]);
|
|
const promptMetrics = {
|
|
promptChars: prompt.length,
|
|
instructionsChars,
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
wakePromptChars: wakePrompt.length,
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
runtimeNoteChars: paperclipEnvNote.length,
|
|
heartbeatPromptChars: renderedPrompt.length,
|
|
};
|
|
|
|
const buildArgs = (resumeSessionId: string | null) => {
|
|
const args = ["-p", "--output-format", "stream-json", "--workspace", effectiveExecutionCwd];
|
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
|
if (model) args.push("--model", model);
|
|
if (mode) args.push("--mode", mode);
|
|
if (autoTrustEnabled) args.push("--yolo");
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
return args;
|
|
};
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
const args = buildArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "cursor",
|
|
command: resolvedCommand,
|
|
cwd: effectiveExecutionCwd,
|
|
commandNotes,
|
|
commandArgs: args,
|
|
env: loggedEnv,
|
|
prompt,
|
|
promptMetrics,
|
|
context,
|
|
});
|
|
}
|
|
|
|
let stdoutLineBuffer = "";
|
|
const emitNormalizedStdoutLine = async (rawLine: string) => {
|
|
const normalized = normalizeCursorStreamLine(rawLine);
|
|
if (!normalized.line) return;
|
|
await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`);
|
|
};
|
|
const flushStdoutChunk = async (chunk: string, finalize = false) => {
|
|
const combined = `${stdoutLineBuffer}${chunk}`;
|
|
const lines = combined.split(/\r?\n/);
|
|
stdoutLineBuffer = lines.pop() ?? "";
|
|
|
|
for (const line of lines) {
|
|
await emitNormalizedStdoutLine(line);
|
|
}
|
|
|
|
if (finalize) {
|
|
const trailing = stdoutLineBuffer.trim();
|
|
stdoutLineBuffer = "";
|
|
if (trailing) {
|
|
await emitNormalizedStdoutLine(trailing);
|
|
}
|
|
}
|
|
};
|
|
|
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
stdin: prompt,
|
|
onSpawn,
|
|
onLog: async (stream, chunk) => {
|
|
if (stream !== "stdout") {
|
|
await onLog(stream, chunk);
|
|
return;
|
|
}
|
|
await flushStdoutChunk(chunk);
|
|
},
|
|
});
|
|
await flushStdoutChunk("", true);
|
|
|
|
return {
|
|
proc,
|
|
parsed: parseCursorJsonl(proc.stdout),
|
|
};
|
|
};
|
|
|
|
const providerFromModel = resolveProviderFromModel(model);
|
|
|
|
const toResult = (
|
|
attempt: {
|
|
proc: {
|
|
exitCode: number | null;
|
|
signal: string | null;
|
|
timedOut: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
parsed: ReturnType<typeof parseCursorJsonl>;
|
|
},
|
|
clearSessionOnMissingSession = false,
|
|
): AdapterExecutionResult => {
|
|
if (attempt.proc.timedOut) {
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: true,
|
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
clearSession: clearSessionOnMissingSession,
|
|
};
|
|
}
|
|
|
|
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? ({
|
|
sessionId: resolvedSessionId,
|
|
cwd: effectiveExecutionCwd,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
...(executionTargetIsRemote
|
|
? {
|
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
|
}
|
|
: {}),
|
|
} as Record<string, unknown>)
|
|
: null;
|
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
const fallbackErrorMessage =
|
|
parsedError ||
|
|
stderrLine ||
|
|
`Cursor exited with code ${attempt.proc.exitCode ?? -1}`;
|
|
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
errorMessage:
|
|
(attempt.proc.exitCode ?? 0) === 0
|
|
? null
|
|
: fallbackErrorMessage,
|
|
usage: attempt.parsed.usage,
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: providerFromModel,
|
|
biller: resolveCursorBiller(effectiveEnv, billingType, providerFromModel),
|
|
model,
|
|
billingType,
|
|
costUsd: attempt.parsed.costUsd,
|
|
resultJson: {
|
|
stdout: attempt.proc.stdout,
|
|
stderr: attempt.proc.stderr,
|
|
},
|
|
summary: attempt.parsed.summary,
|
|
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const initial = await runAttempt(sessionId);
|
|
if (
|
|
sessionId &&
|
|
!initial.proc.timedOut &&
|
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
|
) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const retry = await runAttempt(null);
|
|
return toResult(retry, true);
|
|
}
|
|
return toResult(initial);
|
|
} finally {
|
|
if (paperclipBridge) {
|
|
await paperclipBridge.stop();
|
|
}
|
|
if (restoreRemoteWorkspace) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
|
);
|
|
await restoreRemoteWorkspace();
|
|
}
|
|
if (localSkillsDir) {
|
|
await fs.rm(localSkillsDir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
}
|
|
}
|