forked from farhoodlabs/paperclip
a1b30c9f35
## Thinking Path > - Paperclip is a control plane for autonomous AI companies. > - Issues are the core unit of work, and issue comments are how board users and agents coordinate execution. > - Some issue conversations need to produce plans and approvals instead of immediate implementation work. > - The existing issue contract did not distinguish standard execution comments from planning-oriented issue work. > - This pull request adds an issue work-mode contract and board UI affordances for standard vs planning mode. > - The benefit is that planning-mode issues can be created, displayed, discussed, and carried through agent heartbeat context without losing the normal issue workflow. ## What Changed - Added `standard` / `planning` issue work-mode contracts across DB, shared validators/types, server issue flows, plugin protocol, and adapter heartbeat payloads. - Added an idempotent `0081_optimal_dormammu` migration for `issues.work_mode`, ordered after current `public-gh/master` migrations. - Updated heartbeat/context summaries and issue-thread interaction behavior so planning work mode is preserved when creating suggested follow-up issues. - Added UI support for planning-mode issue creation, issue rows, detail composer styling, and composer work-mode toggles. - Added focused server/shared/UI tests plus a Playwright visual verification spec for planning-mode surfaces. - Rebased the branch onto current `public-gh/master` and added durable planning-mode screenshots under `doc/assets/pap-3368/`. ## Verification - `pnpm --filter @paperclipai/db run check:migrations` - `pnpm exec vitest run --project @paperclipai/shared packages/shared/src/validators/issue.test.ts` - `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/heartbeat-context-summary.test.ts server/src/__tests__/issue-thread-interactions-service.test.ts server/src/__tests__/issues-goal-context-routes.test.ts --pool=forks --poolOptions.forks.isolate=true` - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/IssueChatThread.test.tsx ui/src/components/NewIssueDialog.test.tsx ui/src/components/IssueRow.test.tsx ui/src/pages/IssueDetail.test.tsx` - `pnpm exec vitest run --project @paperclipai/adapter-utils packages/adapter-utils/src/server-utils.test.ts` - `PAPERCLIP_E2E_SKIP_LLM=true npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/planning-mode-visual-verification.spec.ts` ## Screenshots Desktop planning detail:  Desktop planning row:  Desktop staged standard toggle:  Mobile planning detail:  Mobile planning row:  ## Risks - Medium migration risk: this adds a non-null issue column. The migration uses `ADD COLUMN IF NOT EXISTS` so installations that applied an older branch-local migration number can still apply the final numbered migration safely. - Medium contract risk: issue payloads, plugin payloads, and adapter heartbeat payloads now include work mode; compatibility is handled by defaulting missing values to `standard`. - UI risk is moderate because composer controls changed; focused component tests and visual e2e coverage exercise standard vs planning display and toggle behavior. > 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, GPT-5 coding agent in a local Paperclip worktree, with shell/tool use. Exact context-window size is not exposed in this runtime. ## 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 - [x] If this change affects the UI, I have included before/after screenshots - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
800 lines
30 KiB
TypeScript
800 lines
30 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,
|
|
ensureAdapterExecutionTargetFile,
|
|
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
|
prepareAdapterExecutionTargetRuntime,
|
|
readAdapterExecutionTarget,
|
|
resolveAdapterExecutionTargetCommandForLogs,
|
|
runAdapterExecutionTargetProcess,
|
|
runAdapterExecutionTargetShellCommand,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
} from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asStringArray,
|
|
parseObject,
|
|
applyPaperclipWorkspaceEnv,
|
|
buildPaperclipEnv,
|
|
joinPromptSections,
|
|
buildInvocationEnvForLogs,
|
|
ensureAbsoluteDirectory,
|
|
ensurePaperclipSkillSymlink,
|
|
ensurePathInEnv,
|
|
readPaperclipRuntimeSkillEntries,
|
|
readPaperclipIssueWorkModeFromContext,
|
|
resolvePaperclipDesiredSkillNames,
|
|
removeMaintainerOnlySkillSymlinks,
|
|
renderTemplate,
|
|
renderPaperclipWakePrompt,
|
|
shapePaperclipWorkspaceEnvForExecution,
|
|
stringifyPaperclipWakePayload,
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
runChildProcess,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
|
import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js";
|
|
import { ensurePiModelConfiguredAndAvailable } from "./models.js";
|
|
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips");
|
|
const PI_AGENT_SKILLS_DIR = path.join(os.homedir(), ".pi", "agent", "skills");
|
|
|
|
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 parseModelId(model: string | null): string | null {
|
|
if (!model) return null;
|
|
const trimmed = model.trim();
|
|
if (!trimmed.includes("/")) return trimmed || null;
|
|
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
|
|
}
|
|
|
|
async function ensurePiSkillsInjected(
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
|
desiredSkillNames?: string[],
|
|
) {
|
|
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
|
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
|
if (selectedEntries.length === 0) return;
|
|
await fs.mkdir(PI_AGENT_SKILLS_DIR, { recursive: true });
|
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
|
PI_AGENT_SKILLS_DIR,
|
|
selectedEntries.map((entry) => entry.runtimeName),
|
|
);
|
|
for (const skillName of removedSkills) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${PI_AGENT_SKILLS_DIR}\n`,
|
|
);
|
|
}
|
|
|
|
for (const entry of selectedEntries) {
|
|
const target = path.join(PI_AGENT_SKILLS_DIR, entry.runtimeName);
|
|
|
|
try {
|
|
const result = await ensurePaperclipSkillSymlink(entry.source, target);
|
|
if (result === "skipped") continue;
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.runtimeName}" into ${PI_AGENT_SKILLS_DIR}\n`,
|
|
);
|
|
} catch (err) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to inject Pi skill "${entry.runtimeName}" into ${PI_AGENT_SKILLS_DIR}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function buildPiSkillsDir(config: Record<string, unknown>): Promise<string> {
|
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-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;
|
|
}
|
|
|
|
function resolvePiBiller(env: Record<string, string>, provider: string | null): string {
|
|
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
|
}
|
|
|
|
async function ensureSessionsDir(): Promise<string> {
|
|
await fs.mkdir(PAPERCLIP_SESSIONS_DIR, { recursive: true });
|
|
return PAPERCLIP_SESSIONS_DIR;
|
|
}
|
|
|
|
function buildSessionPath(agentId: string, timestamp: string): string {
|
|
const safeTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
return path.join(PAPERCLIP_SESSIONS_DIR, `${safeTimestamp}-${agentId}.jsonl`);
|
|
}
|
|
|
|
function buildRemoteSessionPath(runtimeRootDir: string, agentId: string, timestamp: string): string {
|
|
const safeTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
return path.posix.join(runtimeRootDir, "sessions", `${safeTimestamp}-${agentId}.jsonl`);
|
|
}
|
|
|
|
function normalizeExecutionCwd(candidate: string, remote: boolean): string {
|
|
return remote ? path.posix.normalize(candidate) : path.resolve(candidate);
|
|
}
|
|
|
|
function executionCwdsMatch(saved: string, current: string, remote: boolean): boolean {
|
|
return normalizeExecutionCwd(saved, remote) === normalizeExecutionCwd(current, remote);
|
|
}
|
|
|
|
function readSessionHeaderCwd(raw: string): string | null {
|
|
const headerLine = raw
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean);
|
|
if (!headerLine) return null;
|
|
try {
|
|
const parsed = JSON.parse(headerLine) as Record<string, unknown>;
|
|
if (parsed.type !== "session") return null;
|
|
const cwd = typeof parsed.cwd === "string" ? parsed.cwd.trim() : "";
|
|
return cwd.length > 0 ? cwd : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function readSavedSessionCwd(input: {
|
|
runId: string;
|
|
sessionPath: string;
|
|
executionTarget: ReturnType<typeof readAdapterExecutionTarget>;
|
|
cwd: string;
|
|
env: Record<string, string>;
|
|
timeoutSec: number;
|
|
graceSec: number;
|
|
}): Promise<string | null> {
|
|
if (!input.sessionPath.trim()) return null;
|
|
|
|
if (!adapterExecutionTargetIsRemote(input.executionTarget)) {
|
|
try {
|
|
return readSessionHeaderCwd(await fs.readFile(input.sessionPath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const sessionHeader = await runAdapterExecutionTargetShellCommand(
|
|
input.runId,
|
|
input.executionTarget,
|
|
`if [ -f ${shellQuote(input.sessionPath)} ]; then head -n 1 ${shellQuote(input.sessionPath)}; fi`,
|
|
{
|
|
cwd: input.cwd,
|
|
env: input.env,
|
|
timeoutSec: input.timeoutSec > 0 ? Math.min(input.timeoutSec, 15) : 15,
|
|
graceSec: input.graceSec,
|
|
},
|
|
);
|
|
if (sessionHeader.timedOut || (sessionHeader.exitCode ?? 0) !== 0) return null;
|
|
return readSessionHeaderCwd(sessionHeader.stdout);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
const command = asString(config.command, "pi");
|
|
const model = asString(config.model, "").trim();
|
|
const thinking = asString(config.thinking, "").trim();
|
|
|
|
// Parse model into provider and model id
|
|
const provider = parseModelProvider(model);
|
|
const modelId = parseModelId(model);
|
|
|
|
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 });
|
|
|
|
if (!executionTargetIsRemote) {
|
|
await ensureSessionsDir();
|
|
}
|
|
|
|
const piSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredPiSkillNames = resolvePaperclipDesiredSkillNames(config, piSkillEntries);
|
|
if (!executionTargetIsRemote) {
|
|
await ensurePiSkillsInjected(onLog, piSkillEntries, desiredPiSkillNames);
|
|
}
|
|
|
|
// Build environment
|
|
const envConfig = parseObject(config.env);
|
|
const hasExplicitApiKey =
|
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
|
const 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 [key, value] of Object.entries(envConfig)) {
|
|
if (typeof value === "string") env[key] = value;
|
|
}
|
|
if (!hasExplicitApiKey && authToken) {
|
|
env.PAPERCLIP_API_KEY = authToken;
|
|
}
|
|
|
|
// Prepend installed skill `bin/` dirs to PATH so an agent's bash tool can
|
|
// invoke skill binaries (e.g. `paperclip-get-issue`) by name. Without this,
|
|
// any pi_local agent whose AGENTS.md calls a skill command via bash hits
|
|
// exit 127 "command not found". Only include skills that ensurePiSkillsInjected
|
|
// actually linked — otherwise non-injected skills' binaries would be reachable
|
|
// to the agent.
|
|
const injectedSkillKeys = new Set(desiredPiSkillNames);
|
|
const skillBinDirs = piSkillEntries
|
|
.filter((entry) => injectedSkillKeys.has(entry.key) && entry.source.length > 0)
|
|
.map((entry) => path.join(entry.source, "bin"));
|
|
const mergedEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
const pathKey =
|
|
typeof mergedEnv.Path === "string" && mergedEnv.Path.length > 0 && !mergedEnv.PATH
|
|
? "Path"
|
|
: "PATH";
|
|
const basePath = mergedEnv[pathKey] ?? "";
|
|
if (skillBinDirs.length > 0) {
|
|
const existing = basePath.split(path.delimiter).filter(Boolean);
|
|
const additions = skillBinDirs.filter((dir) => !existing.includes(dir));
|
|
if (additions.length > 0) {
|
|
mergedEnv[pathKey] = [...additions, basePath].filter(Boolean).join(path.delimiter);
|
|
}
|
|
}
|
|
const runtimeEnv = Object.fromEntries(
|
|
Object.entries(mergedEnv).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
),
|
|
);
|
|
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: runtimeEnv,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
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,
|
|
});
|
|
|
|
if (!executionTargetIsRemote) {
|
|
await ensurePiModelConfiguredAndAvailable({
|
|
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 remoteRuntimeRootDir: string | null = null;
|
|
let localSkillsDir: string | null = null;
|
|
let remoteSkillsDir: string | null = null;
|
|
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
|
|
|
|
if (executionTargetIsRemote) {
|
|
try {
|
|
localSkillsDir = await buildPiSkillsDir(config);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Syncing workspace and Pi runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
|
|
);
|
|
const preparedRemoteRuntime = await prepareAdapterExecutionTargetRuntime({
|
|
target: executionTarget,
|
|
adapterKey: "pi",
|
|
workspaceLocalDir: cwd,
|
|
installCommand: SANDBOX_INSTALL_COMMAND,
|
|
detectCommand: command,
|
|
assets: [
|
|
{
|
|
key: "skills",
|
|
localDir: localSkillsDir,
|
|
followSymlinks: true,
|
|
},
|
|
],
|
|
});
|
|
restoreRemoteWorkspace = () => preparedRemoteRuntime.restoreWorkspace();
|
|
if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) {
|
|
env.HOME = preparedRemoteRuntime.runtimeRootDir;
|
|
}
|
|
remoteRuntimeRootDir = preparedRemoteRuntime.runtimeRootDir;
|
|
remoteSkillsDir = preparedRemoteRuntime.assetDirs.skills ?? null;
|
|
} catch (error) {
|
|
await Promise.allSettled([
|
|
restoreRemoteWorkspace?.(),
|
|
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
|
]);
|
|
throw error;
|
|
}
|
|
}
|
|
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(executionTarget)) {
|
|
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
|
|
runId,
|
|
target: executionTarget,
|
|
runtimeRootDir: remoteRuntimeRootDir,
|
|
adapterKey: "pi",
|
|
hostApiToken: env.PAPERCLIP_API_KEY,
|
|
onLog,
|
|
});
|
|
if (paperclipBridge) {
|
|
Object.assign(env, paperclipBridge.env);
|
|
loggedEnv = buildInvocationEnvForLogs(env, {
|
|
runtimeEnv: Object.fromEntries(
|
|
Object.entries(ensurePathInEnv({ ...process.env, ...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 sessionTargetMatches = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget);
|
|
const sessionParamsCwdMatches =
|
|
runtimeSessionCwd.length === 0 ||
|
|
executionCwdsMatch(runtimeSessionCwd, effectiveExecutionCwd, executionTargetIsRemote);
|
|
const savedSessionCwd =
|
|
runtimeSessionId.length > 0
|
|
? await readSavedSessionCwd({
|
|
runId,
|
|
sessionPath: runtimeSessionId,
|
|
executionTarget,
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
})
|
|
: null;
|
|
const sessionHeaderCwdMatches =
|
|
runtimeSessionId.length === 0 ||
|
|
(savedSessionCwd !== null &&
|
|
executionCwdsMatch(savedSessionCwd, effectiveExecutionCwd, executionTargetIsRemote));
|
|
const canResumeSession =
|
|
runtimeSessionId.length > 0 &&
|
|
sessionTargetMatches &&
|
|
sessionParamsCwdMatches &&
|
|
sessionHeaderCwdMatches;
|
|
const sessionPath = canResumeSession
|
|
? runtimeSessionId
|
|
: executionTargetIsRemote && remoteRuntimeRootDir
|
|
? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString())
|
|
: buildSessionPath(agent.id, new Date().toISOString());
|
|
|
|
if (runtimeSessionId && !canResumeSession) {
|
|
const staleSessionCwdNote =
|
|
savedSessionCwd !== null && !sessionHeaderCwdMatches
|
|
? ` Pi stored cwd "${savedSessionCwd}" in the session header, so Paperclip will start a fresh session for "${effectiveExecutionCwd}".`
|
|
: "";
|
|
await onLog(
|
|
"stdout",
|
|
executionTargetIsRemote
|
|
? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution state and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote} Starting a fresh remote session.\n`
|
|
: `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote}\n`,
|
|
);
|
|
}
|
|
|
|
if (!canResumeSession) {
|
|
if (executionTargetIsRemote) {
|
|
await ensureAdapterExecutionTargetFile(runId, executionTarget, sessionPath, {
|
|
cwd,
|
|
env,
|
|
timeoutSec: 15,
|
|
graceSec: 5,
|
|
onLog,
|
|
});
|
|
} else {
|
|
try {
|
|
await fs.writeFile(sessionPath, "", { flag: "wx" });
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle instructions file and build system prompt extension
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const resolvedInstructionsFilePath = instructionsFilePath
|
|
? path.resolve(cwd, instructionsFilePath)
|
|
: "";
|
|
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
|
|
let systemPromptExtension = "";
|
|
let instructionsReadFailed = false;
|
|
if (resolvedInstructionsFilePath) {
|
|
try {
|
|
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
|
|
systemPromptExtension =
|
|
`${instructionsContents}\n\n` +
|
|
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
|
|
`Resolve any relative file references from ${instructionsFileDir}.\n\n` +
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE;
|
|
} catch (err) {
|
|
instructionsReadFailed = true;
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
|
|
);
|
|
// Fall back to base prompt template
|
|
systemPromptExtension = promptTemplate;
|
|
}
|
|
} else {
|
|
systemPromptExtension = promptTemplate;
|
|
}
|
|
|
|
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 renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData);
|
|
const renderedBootstrapPrompt =
|
|
!canResumeSession && bootstrapPromptTemplate.trim().length > 0
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
: "";
|
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession });
|
|
const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0;
|
|
const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
const userPrompt = joinPromptSections([
|
|
renderedBootstrapPrompt,
|
|
wakePrompt,
|
|
sessionHandoffNote,
|
|
renderedHeartbeatPrompt,
|
|
]);
|
|
const promptMetrics = {
|
|
systemPromptChars: renderedSystemPromptExtension.length,
|
|
promptChars: userPrompt.length,
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
wakePromptChars: wakePrompt.length,
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
heartbeatPromptChars: renderedHeartbeatPrompt.length,
|
|
};
|
|
|
|
const commandNotes = (() => {
|
|
if (!resolvedInstructionsFilePath) return [] as string[];
|
|
if (instructionsReadFailed) {
|
|
return [
|
|
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
];
|
|
}
|
|
return [
|
|
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
|
`Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`,
|
|
];
|
|
})();
|
|
|
|
const buildArgs = (sessionFile: string): string[] => {
|
|
const args: string[] = [];
|
|
|
|
// Use JSON mode for structured output with print mode (non-interactive)
|
|
args.push("--mode", "json");
|
|
args.push("-p"); // Non-interactive mode: process prompt and exit
|
|
|
|
// Use --append-system-prompt to extend Pi's default system prompt
|
|
args.push("--append-system-prompt", renderedSystemPromptExtension);
|
|
|
|
if (provider) args.push("--provider", provider);
|
|
if (modelId) args.push("--model", modelId);
|
|
if (thinking) args.push("--thinking", thinking);
|
|
|
|
args.push("--tools", "read,bash,edit,write,grep,find,ls");
|
|
args.push("--session", sessionFile);
|
|
args.push("--skill", remoteSkillsDir ?? PI_AGENT_SKILLS_DIR);
|
|
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
|
|
// Add the user prompt as the last argument
|
|
args.push(userPrompt);
|
|
|
|
return args;
|
|
};
|
|
|
|
const runAttempt = async (sessionFile: string) => {
|
|
const args = buildArgs(sessionFile);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "pi_local",
|
|
command: resolvedCommand,
|
|
cwd: effectiveExecutionCwd,
|
|
commandNotes,
|
|
commandArgs: args,
|
|
env: loggedEnv,
|
|
prompt: userPrompt,
|
|
promptMetrics,
|
|
context,
|
|
});
|
|
}
|
|
|
|
// Buffer stdout by lines to handle partial JSON chunks
|
|
let stdoutBuffer = "";
|
|
const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
|
if (stream === "stderr") {
|
|
// Pass stderr through immediately (not JSONL)
|
|
await onLog(stream, chunk);
|
|
return;
|
|
}
|
|
|
|
// Buffer stdout and emit only complete lines
|
|
stdoutBuffer += chunk;
|
|
const lines = stdoutBuffer.split("\n");
|
|
// Keep the last (potentially incomplete) line in the buffer
|
|
stdoutBuffer = lines.pop() || "";
|
|
|
|
// Emit complete lines
|
|
for (const line of lines) {
|
|
if (line) {
|
|
await onLog(stream, line + "\n");
|
|
}
|
|
}
|
|
};
|
|
|
|
const proc = await runAdapterExecutionTargetProcess(runId, executionTarget, command, args, {
|
|
cwd,
|
|
env: executionTargetIsRemote ? env : runtimeEnv,
|
|
timeoutSec,
|
|
graceSec,
|
|
onSpawn,
|
|
onLog: bufferedOnLog,
|
|
});
|
|
|
|
// Flush any remaining buffer content
|
|
if (stdoutBuffer) {
|
|
await onLog("stdout", stdoutBuffer);
|
|
}
|
|
|
|
return {
|
|
proc,
|
|
rawStderr: proc.stderr,
|
|
parsed: parsePiJsonl(proc.stdout),
|
|
};
|
|
};
|
|
|
|
const toResult = (
|
|
attempt: {
|
|
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
|
rawStderr: string;
|
|
parsed: ReturnType<typeof parsePiJsonl>;
|
|
},
|
|
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 = clearSessionOnMissingSession ? null : sessionPath;
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? {
|
|
sessionId: resolvedSessionId,
|
|
cwd: effectiveExecutionCwd,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
...(executionTargetIsRemote
|
|
? {
|
|
remoteExecution: adapterExecutionTargetSessionIdentity(executionTarget),
|
|
}
|
|
: {}),
|
|
}
|
|
: null;
|
|
|
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
const rawExitCode = attempt.proc.exitCode;
|
|
const parsedError = attempt.parsed.errors.find((error) => error.trim().length > 0) ?? "";
|
|
const effectiveExitCode = (rawExitCode ?? 0) === 0 && parsedError ? 1 : rawExitCode;
|
|
const fallbackErrorMessage = parsedError || stderrLine || `Pi exited with code ${rawExitCode ?? -1}`;
|
|
|
|
return {
|
|
exitCode: effectiveExitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
errorMessage: (effectiveExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
|
usage: {
|
|
inputTokens: attempt.parsed.usage.inputTokens,
|
|
outputTokens: attempt.parsed.usage.outputTokens,
|
|
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
|
|
},
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: provider,
|
|
biller: resolvePiBiller(runtimeEnv, provider),
|
|
model: model,
|
|
billingType: "unknown",
|
|
costUsd: attempt.parsed.usage.costUsd,
|
|
resultJson: {
|
|
stdout: attempt.proc.stdout,
|
|
stderr: attempt.proc.stderr,
|
|
},
|
|
summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(),
|
|
clearSession: Boolean(clearSessionOnMissingSession),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const initial = await runAttempt(sessionPath);
|
|
const initialFailed =
|
|
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0);
|
|
|
|
if (
|
|
canResumeSession &&
|
|
initialFailed &&
|
|
isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
|
) {
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const newSessionPath = executionTargetIsRemote && remoteRuntimeRootDir
|
|
? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString())
|
|
: buildSessionPath(agent.id, new Date().toISOString());
|
|
if (executionTargetIsRemote) {
|
|
await ensureAdapterExecutionTargetFile(runId, executionTarget, newSessionPath, {
|
|
cwd,
|
|
env,
|
|
timeoutSec: 15,
|
|
graceSec: 5,
|
|
onLog,
|
|
});
|
|
} else {
|
|
try {
|
|
await fs.writeFile(newSessionPath, "", { flag: "wx" });
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
const retry = await runAttempt(newSessionPath);
|
|
return toResult(retry, true);
|
|
}
|
|
|
|
return toResult(initial);
|
|
} finally {
|
|
await Promise.all([
|
|
paperclipBridge?.stop(),
|
|
restoreRemoteWorkspace?.(),
|
|
localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(),
|
|
]);
|
|
}
|
|
}
|