Files
PaperClipAI/packages/adapters/opencode-local/src/server/execute.ts
T
Kilianandcommitperclip-bot 28e134873e fix(opencode-local): run in the realized workspace (#11438)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip adapters start each agent in its selected workspace
> - Paperclip can realize a different workspace path for local and
remote runs
> - The OpenCode CLI prefers its inherited `PWD` when no `--dir` option
is present
> - The process `cwd` option does not update the inherited `PWD` value
> - This pull request passes the realized workspace to OpenCode with
`--dir`
> - The benefit is that OpenCode sessions and tools use the workspace
that Paperclip selected

## Linked Issues or Issue Description

No matching issue exists.

Refs #8287. That earlier pull request describes the realized remote
workspace path. It does not make the OpenCode CLI use that path.

I searched open and closed issues and pull requests for `opencode
--dir`, `opencode cwd PWD`, and `opencode workspace directory`. I found
no duplicate.

**What happened?**

Paperclip gives the OpenCode process the correct operating system `cwd`.
However, the adapter does not pass OpenCode's `--dir` option. OpenCode
uses `process.env.PWD` before `process.cwd()` when `--dir` is absent. A
server `PWD` that differs from the realized workspace can therefore
place the OpenCode session and its tools in the wrong directory.

**Expected behavior**

OpenCode must use the workspace that Paperclip realized for the run. An
explicit `--dir` in `extraArgs` must remain valid and must not be
duplicated.

**Steps to reproduce**

1. Start the Paperclip server from directory A.
2. Configure an OpenCode agent with directory B, or realize directory B
on a remote execution target.
3. Start a run without a manual `--dir` value in `extraArgs`.
4. Inspect the OpenCode session directory or run a directory-sensitive
tool.
5. Before this change, OpenCode can use directory A from the inherited
`PWD`.
6. After this change, OpenCode receives `--dir B`.

**Paperclip version or commit**

`b38d6ddb811b3ff3145ff5df60174eb9e7313fe6`

**Deployment mode**

Self-hosted server. The same adapter path is used in local development
and remote execution.

**Installation method**

Built from source.

**Agent adapter(s) involved**

OpenCode.

**Database mode**

Not database-related.

**Additional context**

OpenCode v1.17.13 defines `--dir` and uses the inherited `PWD` as its
fallback root. See the [OpenCode run command
source](https://github.com/anomalyco/opencode/blob/10c894bdeef3618f5666fb506ef7f9491bb964d8/packages/opencode/src/cli/cmd/run.ts#L333-L339).

## What Changed

- Add the realized execution workspace as `--dir` for OpenCode runs.
- Preserve an explicit `--dir` or `--dir=...` value from `extraArgs`.
- Stop option detection at the first `--` terminator.
- Extend the remote execution tests for realized, explicit, and
post-terminator directory arguments.

## Verification

- `pnpm exec vitest run packages/adapters/opencode-local/src` passed: 7
files and 40 tests.
- `pnpm --filter @paperclipai/adapter-opencode-local typecheck` passed.
- `pnpm --filter @paperclipai/adapter-opencode-local build` passed.
- `pnpm -r typecheck` passed for all 31 selected workspace projects.
- `pnpm build` passed for all selected workspace projects.
- `git diff --check` passed.

I also ran `pnpm test:run`. It completed with 334 test files passing and
2 test files failing. It reported 3,790 passing tests and 4 skipped
tests. The two failures are in `issue-monitor-scheduler.test.ts` and
`workspace-runtime.test.ts`. Each failure reproduces on the unchanged
base commit after this pull request is removed. Neither test imports or
covers the OpenCode adapter.

## Risks

- The behavior change is limited to OpenCode run arguments.
- A custom old OpenCode binary that does not support `--dir` will reject
the option. The current official OpenCode v1.17.13 CLI supports it.
- An explicit operator `--dir` remains authoritative. The adapter does
not add a duplicate option because OpenCode's parser converts repeated
string options to an array.
- This change does not modify schemas, migrations, APIs, telemetry, or
user data.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex based on GPT-5. The runtime did not expose a more specific
model ID or context-window size. The agent used high-depth reasoning,
repository access, shell execution, and GitHub tools.

## 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
- [ ] 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: commitperclip-bot <305974387+commitperclip-bot@users.noreply.github.com>
2026-10-10 10:46:21 -05:00

823 lines
34 KiB
TypeScript

import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary";
import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint";
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,
overrideAdapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
adapterExecutionTargetDuplexObservabilityRecorder,
adapterExecutionTargetEnablesSandboxDuplexBridge,
readAdapterExecutionTarget,
readAdapterExecutionTargetHomeDir,
resolveAdapterExecutionTargetTimeout,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
asString,
asNumber,
asStringArray,
parseObject,
buildPaperclipEnv,
buildRuntimeToolsEnv,
joinPromptSections,
buildInvocationEnvForLogs,
ensureAbsoluteDirectory,
ensurePaperclipSkillSymlink,
ensurePathInEnv,
refreshPaperclipWorkspaceEnvForExecution,
renderTemplate,
hydrateFreshSessionHandoff,
selectPaperclipPromptSections,
selectInitialCommunicationGuidance,
isPaperclipRecoveryWakePayload,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
runChildProcess,
isPaperclipSkillSourceMissing,
readPaperclipRuntimeSkillEntries,
readPaperclipIssueWorkModeFromContext,
resolveLegacyPaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl, createOpenCodeJsonlParser } from "./parse.js";
import {
ensureOpenCodeModelConfiguredAndAvailable,
isTruthyEnvFlag,
parseOpenCodeModelsOutput,
requireOpenCodeModelId,
} from "./models.js";
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
import { prepareOpenCodeRuntimeConfig, prepareManagedOpenCodeRemoteHomes } from "./runtime-config.js";
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
import { resolveOpenCodeSkillsHome } from "./skills.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 parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return null;
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function resolveOpenCodeBiller(env: Record<string, string>, provider: string | null): string {
return provider === "openai" ? inferOpenAiCompatibleBiller(env, "openai") ?? "unknown" : provider ?? "unknown";
}
const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20;
const REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC = 120;
export async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: {
runId: string;
executionTarget: NonNullable<AdapterExecutionContext["executionTarget"]>;
command: string;
model: string;
cwd: string;
env: Record<string, string>;
timeoutSec: number;
graceSec: number;
}) {
const model = requireOpenCodeModelId(input.model);
// When the caller opts into OPENCODE_ALLOW_ALL_MODELS, OpenCode accepts any
// provider/model at run time (e.g. gateway-routed models that never appear in
// `opencode models` output). Honour that on the REMOTE path too by skipping the
// remote availability probe; we still enforce the provider/model format above.
// Mirrors the local ensureOpenCodeModelConfiguredAndAvailable bypass. Prefer the
// explicit run env, then the process env.
if (isTruthyEnvFlag(input.env.OPENCODE_ALLOW_ALL_MODELS ?? process.env.OPENCODE_ALLOW_ALL_MODELS)) {
return;
}
const defaultProbeTimeoutSec =
input.executionTarget.kind === "remote" && input.executionTarget.transport === "sandbox"
? REMOTE_OPENCODE_MODELS_PROBE_SANDBOX_TIMEOUT_SEC
: REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC;
const probeTimeoutSec = input.timeoutSec > 0
? Math.min(input.timeoutSec, defaultProbeTimeoutSec)
: defaultProbeTimeoutSec;
const probe = await runAdapterExecutionTargetProcess(
input.runId,
input.executionTarget,
input.command,
["models"],
{
cwd: input.cwd,
env: input.env,
timeoutSec: probeTimeoutSec,
graceSec: input.graceSec,
onLog: async () => {},
},
);
// The remote availability probe is a best-effort pre-flight guard, not a gate.
// If `opencode models` itself cannot run on the target — timeout, transient CLI
// error, provider hiccup — do NOT abort the run. The real invocation is
// authoritative, so a probe that can't execute must never be fatal. (Previously
// these threw and crashed runs mid-flight, losing the agent's work + disposition.)
if (probe.timedOut) {
console.warn(
`[opencode-local] Remote model availability probe for "${model}" timed out after ${probeTimeoutSec}s; proceeding with the configured model.`,
);
return;
}
if ((probe.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout);
console.warn(
`[opencode-local] Remote \`opencode models\` could not run for "${model}"${
detail ? ` (${detail})` : ""
}; proceeding with the configured model.`,
);
return;
}
const models = parseOpenCodeModelsOutput(probe.stdout);
if (models.length === 0) {
console.warn(
`[opencode-local] Remote \`opencode models\` returned no models; proceeding with the configured model "${model}".`,
);
return;
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
}
async function ensureOpenCodeSkillsInjected(
onLog: AdapterExecutionContext["onLog"],
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
desiredSkillNames?: string[],
skillsHome = resolveOpenCodeSkillsHome({}),
) {
await fs.mkdir(skillsHome, { recursive: true });
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
const removedSkills = await removeMaintainerOnlySkillSymlinks(
skillsHome,
selectedEntries.map((entry) => entry.runtimeName),
);
for (const skillName of removedSkills) {
await onLog(
"stderr",
`[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`,
);
}
for (const entry of selectedEntries) {
const target = path.join(skillsHome, entry.runtimeName);
try {
const result = await ensurePaperclipSkillSymlink(entry.source, target);
if (result === "skipped") continue;
await onLog(
"stderr",
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.key}" into ${skillsHome}\n`,
);
} catch (err) {
await onLog(
"stderr",
`[paperclip] Failed to inject OpenCode skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
}
async function buildOpenCodeSkillsDir(config: Record<string, unknown>): Promise<string> {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-skills-"));
const target = path.join(tmp, "skills");
await fs.mkdir(target, { recursive: true });
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredNames = new Set(resolveLegacyPaperclipDesiredSkillNames(config, availableEntries));
for (const entry of availableEntries) {
if (!desiredNames.has(entry.key)) continue;
if (isPaperclipSkillSourceMissing(entry)) continue;
await fs.symlink(entry.source, path.join(target, entry.runtimeName));
}
return target;
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const providerStop = createProviderStoppedBoundary(ctx.onProviderStopped);
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,
context.conversationMode === true
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "opencode");
const model = asString(config.model, "").trim();
const variant = asString(config.variant, "").trim();
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();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredOpenCodeSkillNames = resolveLegacyPaperclipDesiredSkillNames(config, openCodeSkillEntries);
if (!executionTargetIsRemote) {
await ensureOpenCodeSkillsInjected(
onLog,
openCodeSkillEntries,
desiredOpenCodeSkillNames,
resolveOpenCodeSkillsHome(config),
);
}
const envConfig = parseObject(config.env);
const env: Record<string, string> = {
...buildPaperclipEnv(agent, ctx.agentIdentity),
...buildRuntimeToolsEnv(ctx.runtimeTools),
};
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 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(",");
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
// Prevent OpenCode from writing an opencode.json config file into the
// project working directory (which would pollute the git repo). Model
// selection is already handled via the --model CLI flag. Set after the
// envConfig loop so user overrides cannot disable this guard.
env.OPENCODE_DISABLE_PROJECT_CONFIG = "true";
if (authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config });
const localRuntimeConfigHome =
preparedRuntimeConfig.notes.length > 0 ? preparedRuntimeConfig.env.XDG_CONFIG_HOME : "";
try {
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const adapterExecutionTimeout = resolveAdapterExecutionTargetTimeout(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const timeoutSec = adapterExecutionTimeout.timeoutSec;
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env: runtimeEnv,
timeoutSec,
graceSec,
onLog,
});
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: SANDBOX_INSTALL_COMMAND,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
if (!executionTargetIsRemote) {
await ensureOpenCodeModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
});
}
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
let localSkillsDir: string | null = null;
let remoteRuntimeRootDir: string | null = null;
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTarget?.kind === "remote") {
localSkillsDir = await buildOpenCodeSkillsDir(config);
await onLog(
"stdout",
`[paperclip] Syncing workspace and OpenCode runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "opencode",
timeoutSec,
workspaceLocalDir: cwd,
installCommand: SANDBOX_INSTALL_COMMAND,
detectCommand: command,
onProgress: (line) => onLog("stdout", line),
onRuntimeProgress: ctx.onRuntimeProgress,
assets: [
{
key: "skills",
localDir: localSkillsDir,
followSymlinks: true,
},
...(localRuntimeConfigHome
? [{
key: "xdgConfig",
localDir: localRuntimeConfigHome,
}]
: []),
],
});
restoreRemoteWorkspace = () =>
preparedExecutionTargetRuntime.restoreWorkspace((line) => onLog("stdout", line));
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env: preparedRuntimeConfig.env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
preparedRuntimeConfig.env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
}
if (localRuntimeConfigHome && preparedExecutionTargetRuntime.assetDirs.xdgConfig) {
preparedRuntimeConfig.env.XDG_CONFIG_HOME = preparedExecutionTargetRuntime.assetDirs.xdgConfig;
}
prepareManagedOpenCodeRemoteHomes({
env: preparedRuntimeConfig.env,
config,
runtimeRootDir: preparedExecutionTargetRuntime.runtimeRootDir,
runId,
configDir: preparedExecutionTargetRuntime.assetDirs.xdgConfig,
});
const remoteHomeDir = config.managedAiConnection
? preparedRuntimeConfig.env.HOME
: managedHome && preparedExecutionTargetRuntime.runtimeRootDir
? preparedExecutionTargetRuntime.runtimeRootDir
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
cwd,
env: preparedRuntimeConfig.env,
timeoutSec,
graceSec,
onLog,
});
if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) {
const remoteSkillsDir = path.posix.join(remoteHomeDir, ".claude", "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: preparedRuntimeConfig.env, timeoutSec, graceSec, onLog },
);
}
await ensureRemoteOpenCodeModelConfiguredAndAvailable({
runId,
executionTarget,
command,
model,
cwd,
env: preparedRuntimeConfig.env,
timeoutSec,
graceSec,
});
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
runId,
target: runtimeExecutionTarget,
enableSandboxDuplexBridge: adapterExecutionTargetEnablesSandboxDuplexBridge(runtimeExecutionTarget),
duplexObservabilityRecorder: adapterExecutionTargetDuplexObservabilityRecorder(runtimeExecutionTarget),
runtimeRootDir: remoteRuntimeRootDir,
adapterKey: "opencode",
timeoutSec,
hostApiToken: preparedRuntimeConfig.env.PAPERCLIP_API_KEY,
onLog,
});
if (paperclipBridge) {
Object.assign(preparedRuntimeConfig.env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, {
runtimeEnv: Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
),
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, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] OpenCode 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] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
"stdout",
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
const notes = [...preparedRuntimeConfig.notes];
if (!resolvedInstructionsFilePath) return notes;
if (instructionsPrefix.length > 0) {
notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`);
notes.push(
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
);
return notes;
}
notes.push(
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, 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 buildPrompt = (resumedSession: boolean) => {
const renderedBootstrapPrompt =
!resumedSession && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const { taskContextNote, wakePrompt } = selectPaperclipPromptSections(context, {
resumedSession,
includeCommunicationGuidance: false,
});
const shouldUseResumeDeltaPrompt = resumedSession && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt || isPaperclipRecoveryWakePayload(context.paperclipWake)
? ""
: renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const basePrompt = joinPromptSections([
instructionsPrefix,
renderedBootstrapPrompt,
wakePrompt,
taskContextNote,
sessionHandoffNote,
renderedPrompt,
]);
return {
basePrompt,
promptMetrics: {
promptChars: basePrompt.length,
instructionsChars: instructionsPrefix.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
taskContextChars: taskContextNote.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
},
};
};
// Optional diagnostic: surface OpenCode's own logs on stderr (captured into the
// run result) so failures that OpenCode otherwise wraps as an opaque
// "Unexpected server error" can be diagnosed in remote/sandbox runs where the
// log file is unreachable. Toggle via PAPERCLIP_OPENCODE_PRINT_LOGS (run env,
// then process env).
const printLogs = isTruthyEnvFlag(
env.PAPERCLIP_OPENCODE_PRINT_LOGS ?? process.env.PAPERCLIP_OPENCODE_PRINT_LOGS,
);
const buildArgs = (resumeSessionId: string | null) => {
const args = ["run", "--format", "json"];
if (printLogs) args.push("--print-logs");
if (resumeSessionId) args.push("--session", resumeSessionId);
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
// OpenCode prefers the inherited PWD over process.cwd(). Use the realized
// workspace unless the operator already selected a directory explicitly.
const optionTerminator = extraArgs.indexOf("--");
const extraOptionArgs = optionTerminator === -1 ? extraArgs : extraArgs.slice(0, optionTerminator);
if (!extraOptionArgs.some((arg) => arg === "--dir" || arg.startsWith("--dir="))) {
args.push("--dir", effectiveExecutionCwd);
}
if (extraArgs.length > 0) args.push(...extraArgs);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
await hydrateFreshSessionHandoff(ctx, { resumedSession: Boolean(resumeSessionId) });
const { basePrompt, promptMetrics } = buildPrompt(Boolean(resumeSessionId));
const prompt = joinPromptSections([
selectInitialCommunicationGuidance(context, { resumedSession: Boolean(resumeSessionId) }),
basePrompt,
]);
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "opencode_local",
command: resolvedCommand,
cwd: effectiveExecutionCwd,
commandNotes,
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: loggedEnv,
prompt,
promptMetrics: { ...promptMetrics, promptChars: prompt.length },
context,
});
}
const consumeAccounting = createOpenCodeJsonlParser();
let hasAccounting = false;
const accountingLog = createUsageCheckpointLog(onLog, ctx.onUsage ?? (async () => {}), stdout => {
hasAccounting = true;
const parsed = consumeAccounting(stdout);
const provider = parseModelProvider(model || null);
return { usage: parsed.usageReported ? parsed.usage : undefined, costUsd: parsed.costUsd,
costStatus: parsed.usageComplete || parsed.costUsd != null ? undefined : "unpriced",
usageBasis: "per_run", provider, biller: resolveOpenCodeBiller(runtimeEnv, provider), billingType: "unknown", model, complete: false };
});
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
onProcessStopped: providerStop.beginInvocation(),
cwd,
env: preparedRuntimeConfig.env,
stdin: prompt,
timeoutSec,
graceSec,
onSpawn,
onRuntimeProgress: ctx.onRuntimeProgress,
onLog: accountingLog,
runLogTail: paperclipBridge?.runLogTail,
settleRunDisposition: paperclipBridge?.settleRunDisposition,
});
// Parse any unterminated final record before deciding whether its usage
// is complete. A clean exit alone cannot turn absent counters into zero.
await accountingLog.flush();
const retainedAccounting = consumeAccounting("");
await accountingLog.flush({ complete: proc.exitCode === 0 && !proc.timedOut && !proc.signal
&& (retainedAccounting.usageComplete || retainedAccounting.costUsd != null) });
// Display output is capped by the process transport. Keep accounting
// from the full stream, including when no checkpoint callback is installed.
const parsed = parseOpenCodeJsonl(proc.stdout);
if (hasAccounting) {
const retained = consumeAccounting("");
parsed.usage = retained.usage;
parsed.usageReported = retained.usageReported;
parsed.usageComplete = retained.usageComplete;
parsed.costUsd = retained.costUsd;
}
return { proc, rawStderr: proc.stderr, parsed };
};
const toResult = (
attempt: {
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string; errorCode?: string | null };
rawStderr: string;
parsed: ReturnType<typeof parseOpenCodeJsonl>;
},
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
usageComplete: false,
usage: attempt.parsed.usageReported ? attempt.parsed.usage : undefined,
usageBasis: "per_run",
provider: parseModelProvider(model || null),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(model || null)),
model,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,
costStatus: attempt.parsed.usageComplete || attempt.parsed.costUsd != null ? undefined : "unpriced",
// A provider or output-observation timeout can arrive before the
// configured execution deadline. The boolean does not prove which
// timer fired, and provider stderr is not safe exception text.
errorMessage: "OpenCode execution timed out",
resultJson: { adapterExecutionTimeout },
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId =
attempt.parsed.sessionId ??
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd: effectiveExecutionCwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
} as Record<string, unknown>)
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const rawExitCode = attempt.proc.exitCode;
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
const modelId = model || null;
return {
exitCode: synthesizedExitCode,
signal: attempt.proc.signal,
timedOut: false,
usageComplete: attempt.proc.exitCode === 0 && !attempt.proc.signal
&& (attempt.parsed.usageComplete || attempt.parsed.costUsd != null),
usageBasis: "per_run",
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
// Forward the transport-level error code from the run-disposition seam.
// A lost duplex control channel surfaces the typed `duplex_channel_lost`
// code; every other result carries no code here.
errorCode: attempt.proc.errorCode ?? null,
usage: attempt.parsed.usageReported ? attempt.parsed.usage : undefined,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: parseModelProvider(modelId),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,
costStatus: attempt.parsed.usageComplete || attempt.parsed.costUsd != null ? undefined : "unpriced",
resultJson: {
adapterExecutionTimeout,
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
};
};
try {
const initial = await runAttempt(sessionId);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
if (
sessionId &&
initialFailed &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stdout",
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);
}
return toResult(initial);
} finally {
try {
await providerStop.collectBeforeRestore();
} finally {
await Promise.all([
paperclipBridge?.stop(),
restoreRemoteWorkspace?.(),
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
]);
}
}
} finally {
await preparedRuntimeConfig.cleanup();
}
}