@paperclipai/adapter-acpx-local 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/format-event.d.ts +2 -0
- package/dist/cli/format-event.d.ts.map +1 -0
- package/dist/cli/format-event.js +128 -0
- package/dist/cli/format-event.js.map +1 -0
- package/dist/cli/format-event.test.d.ts +2 -0
- package/dist/cli/format-event.test.d.ts.map +1 -0
- package/dist/cli/format-event.test.js +88 -0
- package/dist/cli/format-event.test.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/server/config-schema.d.ts +3 -0
- package/dist/server/config-schema.d.ts.map +1 -0
- package/dist/server/config-schema.js +94 -0
- package/dist/server/config-schema.js.map +1 -0
- package/dist/server/execute.d.ts +18 -0
- package/dist/server/execute.d.ts.map +1 -0
- package/dist/server/execute.js +1010 -0
- package/dist/server/execute.js.map +1 -0
- package/dist/server/execute.test.d.ts +2 -0
- package/dist/server/execute.test.d.ts.map +1 -0
- package/dist/server/execute.test.js +330 -0
- package/dist/server/execute.test.js.map +1 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +6 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/session-codec.d.ts +3 -0
- package/dist/server/session-codec.d.ts.map +1 -0
- package/dist/server/session-codec.js +48 -0
- package/dist/server/session-codec.js.map +1 -0
- package/dist/server/skills.d.ts +4 -0
- package/dist/server/skills.d.ts.map +1 -0
- package/dist/server/skills.js +86 -0
- package/dist/server/skills.js.map +1 -0
- package/dist/server/test.d.ts +3 -0
- package/dist/server/test.d.ts.map +1 -0
- package/dist/server/test.js +267 -0
- package/dist/server/test.js.map +1 -0
- package/dist/server/test.test.d.ts +2 -0
- package/dist/server/test.test.d.ts.map +1 -0
- package/dist/server/test.test.js +38 -0
- package/dist/server/test.test.js.map +1 -0
- package/dist/ui/build-config.d.ts +3 -0
- package/dist/ui/build-config.d.ts.map +1 -0
- package/dist/ui/build-config.js +143 -0
- package/dist/ui/build-config.js.map +1 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/parse-stdout.d.ts +3 -0
- package/dist/ui/parse-stdout.d.ts.map +1 -0
- package/dist/ui/parse-stdout.js +148 -0
- package/dist/ui/parse-stdout.js.map +1 -0
- package/dist/ui/parse-stdout.test.d.ts +2 -0
- package/dist/ui/parse-stdout.test.d.ts.map +1 -0
- package/dist/ui/parse-stdout.test.js +106 -0
- package/dist/ui/parse-stdout.test.js.map +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { readAdapterExecutionTarget, adapterExecutionTargetSessionIdentity } from "@paperclipai/adapter-utils/execution-target";
|
|
7
|
+
import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, applyPaperclipWorkspaceEnv, asNumber, asString, buildInvocationEnvForLogs, buildPaperclipEnv, ensureAbsoluteDirectory, ensurePathInEnv, joinPromptSections, materializePaperclipSkillCopy, parseObject, readPaperclipRuntimeSkillEntries, renderPaperclipWakePrompt, renderTemplate, resolvePaperclipDesiredSkillNames, stringifyPaperclipWakePayload, } from "@paperclipai/adapter-utils/server-utils";
|
|
8
|
+
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
|
9
|
+
import { createAcpRuntime, createAgentRegistry, createRuntimeStore, isAcpRuntimeError, } from "acpx/runtime";
|
|
10
|
+
import { DEFAULT_ACPX_LOCAL_AGENT, DEFAULT_ACPX_LOCAL_MODE, DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS, DEFAULT_ACPX_LOCAL_PERMISSION_MODE, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC, } from "../index.js";
|
|
11
|
+
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const DEFAULT_WARM_HANDLE_IDLE_MS = 15 * 60 * 1000;
|
|
13
|
+
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
|
|
14
|
+
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
|
|
15
|
+
const defaultWarmHandles = new Map();
|
|
16
|
+
function stableJson(value) {
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
19
|
+
if (value && typeof value === "object") {
|
|
20
|
+
return `{${Object.entries(value)
|
|
21
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
22
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
|
23
|
+
.join(",")}}`;
|
|
24
|
+
}
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
function shortHash(value) {
|
|
28
|
+
return createHash("sha256").update(stableJson(value)).digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
function defaultPaperclipInstanceDir() {
|
|
31
|
+
const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip");
|
|
32
|
+
const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default";
|
|
33
|
+
return path.join(home, "instances", instanceId);
|
|
34
|
+
}
|
|
35
|
+
function defaultStateDir(companyId, agentId) {
|
|
36
|
+
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acpx-local", "agents", agentId);
|
|
37
|
+
}
|
|
38
|
+
function resolveManagedCodexHomeDir(companyId) {
|
|
39
|
+
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home");
|
|
40
|
+
}
|
|
41
|
+
function packageRootDir() {
|
|
42
|
+
return path.resolve(__moduleDir, "../..");
|
|
43
|
+
}
|
|
44
|
+
function resolveBuiltInAgentCommand(agent) {
|
|
45
|
+
const binName = agent === "claude"
|
|
46
|
+
? "claude-agent-acp"
|
|
47
|
+
: agent === "codex"
|
|
48
|
+
? "codex-acp"
|
|
49
|
+
: null;
|
|
50
|
+
if (!binName)
|
|
51
|
+
return null;
|
|
52
|
+
return path.join(packageRootDir(), "node_modules", ".bin", binName);
|
|
53
|
+
}
|
|
54
|
+
function normalizeAgent(config) {
|
|
55
|
+
const agent = asString(config.agent, DEFAULT_ACPX_LOCAL_AGENT).trim();
|
|
56
|
+
return agent || DEFAULT_ACPX_LOCAL_AGENT;
|
|
57
|
+
}
|
|
58
|
+
async function pathExists(candidate) {
|
|
59
|
+
return fs.access(candidate).then(() => true).catch(() => false);
|
|
60
|
+
}
|
|
61
|
+
async function ensureParentDir(target) {
|
|
62
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
async function writeFileAtomically(input) {
|
|
65
|
+
await ensureParentDir(input.target);
|
|
66
|
+
const tempPath = `${input.target}.tmp-${process.pid}-${randomUUID()}`;
|
|
67
|
+
const handle = await fs.open(tempPath, "wx", input.mode);
|
|
68
|
+
try {
|
|
69
|
+
await handle.writeFile(input.contents, "utf8");
|
|
70
|
+
await handle.close();
|
|
71
|
+
await fs.rename(tempPath, input.target);
|
|
72
|
+
await fs.chmod(input.target, input.mode).catch(() => { });
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
await handle.close().catch(() => { });
|
|
76
|
+
await fs.rm(tempPath, { force: true }).catch(() => { });
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function ensureSymlink(target, source) {
|
|
81
|
+
const resolvedSource = path.resolve(source);
|
|
82
|
+
const existing = await fs.lstat(target).catch(() => null);
|
|
83
|
+
if (!existing) {
|
|
84
|
+
await ensureParentDir(target);
|
|
85
|
+
await fs.symlink(resolvedSource, target);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!existing.isSymbolicLink()) {
|
|
89
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
90
|
+
await fs.symlink(resolvedSource, target);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const linkedPath = await fs.readlink(target).catch(() => null);
|
|
94
|
+
if (!linkedPath)
|
|
95
|
+
return;
|
|
96
|
+
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
|
97
|
+
if (resolvedLinkedPath === resolvedSource)
|
|
98
|
+
return;
|
|
99
|
+
await fs.unlink(target);
|
|
100
|
+
await fs.symlink(resolvedSource, target);
|
|
101
|
+
}
|
|
102
|
+
async function ensureCopiedFile(target, source) {
|
|
103
|
+
if (await pathExists(target))
|
|
104
|
+
return;
|
|
105
|
+
await ensureParentDir(target);
|
|
106
|
+
await fs.copyFile(source, target);
|
|
107
|
+
}
|
|
108
|
+
async function prepareManagedCodexHome(input) {
|
|
109
|
+
const { sourceHome, targetHome, onLog } = input;
|
|
110
|
+
if (path.resolve(sourceHome) === path.resolve(targetHome))
|
|
111
|
+
return targetHome;
|
|
112
|
+
await fs.mkdir(targetHome, { recursive: true });
|
|
113
|
+
const authJson = path.join(sourceHome, "auth.json");
|
|
114
|
+
if (await pathExists(authJson))
|
|
115
|
+
await ensureSymlink(path.join(targetHome, "auth.json"), authJson);
|
|
116
|
+
for (const name of ["config.json", "config.toml", "instructions.md"]) {
|
|
117
|
+
const source = path.join(sourceHome, name);
|
|
118
|
+
if (await pathExists(source))
|
|
119
|
+
await ensureCopiedFile(path.join(targetHome, name), source);
|
|
120
|
+
}
|
|
121
|
+
await onLog("stdout", `[paperclip] Using Paperclip-managed ACPX Codex home "${targetHome}" (seeded from "${sourceHome}").\n`);
|
|
122
|
+
return targetHome;
|
|
123
|
+
}
|
|
124
|
+
async function hashPathContents(candidate, hash, relativePath, seenDirectories) {
|
|
125
|
+
const stat = await fs.lstat(candidate);
|
|
126
|
+
if (stat.isSymbolicLink()) {
|
|
127
|
+
hash.update(`symlink-skipped:${relativePath}\n`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (stat.isDirectory()) {
|
|
131
|
+
const realDir = await fs.realpath(candidate).catch(() => candidate);
|
|
132
|
+
hash.update(`dir:${relativePath}\n`);
|
|
133
|
+
if (seenDirectories.has(realDir)) {
|
|
134
|
+
hash.update("loop\n");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
seenDirectories.add(realDir);
|
|
138
|
+
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
|
139
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;
|
|
142
|
+
await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (stat.isFile()) {
|
|
147
|
+
hash.update(`file:${relativePath}\n`);
|
|
148
|
+
hash.update(await fs.readFile(candidate));
|
|
149
|
+
hash.update("\n");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
hash.update(`other:${relativePath}:${stat.mode}\n`);
|
|
153
|
+
}
|
|
154
|
+
async function buildSkillSetKey(input) {
|
|
155
|
+
const hash = createHash("sha256");
|
|
156
|
+
hash.update(`paperclip-acpx-${input.label}-skills:v1\n`);
|
|
157
|
+
const sorted = [...input.skills].sort((left, right) => left.runtimeName.localeCompare(right.runtimeName));
|
|
158
|
+
for (const entry of sorted) {
|
|
159
|
+
hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);
|
|
160
|
+
await hashPathContents(entry.source, hash, entry.runtimeName, new Set());
|
|
161
|
+
}
|
|
162
|
+
return hash.digest("hex");
|
|
163
|
+
}
|
|
164
|
+
async function resolveSelectedRuntimeSkills(config) {
|
|
165
|
+
const allSkills = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
166
|
+
const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills);
|
|
167
|
+
const desiredSet = new Set(desiredSkillNames);
|
|
168
|
+
return {
|
|
169
|
+
allSkills,
|
|
170
|
+
selectedSkills: allSkills.filter((entry) => desiredSet.has(entry.key)),
|
|
171
|
+
desiredSkillNames,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function prepareClaudeSkillRuntime(input) {
|
|
175
|
+
const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config);
|
|
176
|
+
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "claude" });
|
|
177
|
+
const bundleRoot = path.join(input.stateDir, "runtime-skills", "claude", skillSetKey);
|
|
178
|
+
const skillsHome = path.join(bundleRoot, ".claude", "skills");
|
|
179
|
+
await fs.mkdir(skillsHome, { recursive: true });
|
|
180
|
+
for (const entry of selectedSkills) {
|
|
181
|
+
const target = path.join(skillsHome, entry.runtimeName);
|
|
182
|
+
try {
|
|
183
|
+
const result = await materializePaperclipSkillCopy(entry.source, target);
|
|
184
|
+
if (result.skippedSymlinks.length > 0) {
|
|
185
|
+
await input.onLog("stdout", `[paperclip] Materialized ACPX Claude skill "${entry.runtimeName}" into ${skillsHome} and skipped ${result.skippedSymlinks.length} symlink(s).\n`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
await input.onLog("stderr", `[paperclip] Failed to materialize ACPX Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const selectedNames = selectedSkills.map((entry) => entry.runtimeName).sort();
|
|
193
|
+
const promptInstructions = selectedSkills.length > 0
|
|
194
|
+
? [
|
|
195
|
+
"Paperclip has materialized selected runtime skills for this ACPX Claude session.",
|
|
196
|
+
`Skill root: ${skillsHome}`,
|
|
197
|
+
selectedNames.length > 0 ? `Selected skills: ${selectedNames.join(", ")}` : "",
|
|
198
|
+
"When a task calls for one of these skills, read its SKILL.md from that root and follow it.",
|
|
199
|
+
].filter(Boolean).join("\n")
|
|
200
|
+
: "";
|
|
201
|
+
return {
|
|
202
|
+
identity: {
|
|
203
|
+
mode: "claude",
|
|
204
|
+
skillSetKey,
|
|
205
|
+
desiredSkillNames,
|
|
206
|
+
selectedSkills: selectedNames,
|
|
207
|
+
skillRoot: selectedSkills.length > 0 ? skillsHome : null,
|
|
208
|
+
},
|
|
209
|
+
promptInstructions,
|
|
210
|
+
commandNotes: selectedSkills.length > 0
|
|
211
|
+
? [`Materialized ${selectedSkills.length} Paperclip skill(s) for ACPX Claude at ${skillsHome}.`]
|
|
212
|
+
: [],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
async function readManagedCodexSkillsManifest(skillsHome) {
|
|
216
|
+
const manifestPath = path.join(skillsHome, PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST);
|
|
217
|
+
try {
|
|
218
|
+
const raw = JSON.parse(await fs.readFile(manifestPath, "utf8"));
|
|
219
|
+
const parsed = parseObject(raw);
|
|
220
|
+
const skills = Array.isArray(parsed.managedSkillNames)
|
|
221
|
+
? parsed.managedSkillNames.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
222
|
+
: [];
|
|
223
|
+
return new Set(skills);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return new Set();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function writeManagedCodexSkillsManifest(skillsHome, skillNames) {
|
|
230
|
+
const managedSkillNames = Array.from(new Set(skillNames)).sort();
|
|
231
|
+
await fs.writeFile(path.join(skillsHome, PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST), `${JSON.stringify({ version: 1, managedSkillNames }, null, 2)}\n`, "utf8");
|
|
232
|
+
}
|
|
233
|
+
async function removeSkillTarget(target) {
|
|
234
|
+
const existing = await fs.lstat(target).catch(() => null);
|
|
235
|
+
if (!existing)
|
|
236
|
+
return false;
|
|
237
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
async function reconcileManagedCodexSkills(input) {
|
|
241
|
+
const desired = new Set(input.selectedSkills.map((entry) => entry.runtimeName));
|
|
242
|
+
const managed = await readManagedCodexSkillsManifest(input.skillsHome);
|
|
243
|
+
const availableByRuntimeName = new Map(input.allSkills.map((entry) => [entry.runtimeName, entry]));
|
|
244
|
+
for (const name of managed) {
|
|
245
|
+
if (desired.has(name))
|
|
246
|
+
continue;
|
|
247
|
+
if (await removeSkillTarget(path.join(input.skillsHome, name))) {
|
|
248
|
+
await input.onLog("stdout", `[paperclip] Revoked ACPX Codex skill "${name}" from ${input.skillsHome}\n`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const entry of input.allSkills) {
|
|
252
|
+
if (desired.has(entry.runtimeName) || managed.has(entry.runtimeName))
|
|
253
|
+
continue;
|
|
254
|
+
const target = path.join(input.skillsHome, entry.runtimeName);
|
|
255
|
+
const existing = await fs.lstat(target).catch(() => null);
|
|
256
|
+
if (!existing?.isSymbolicLink())
|
|
257
|
+
continue;
|
|
258
|
+
const linkedPath = await fs.readlink(target).catch(() => null);
|
|
259
|
+
if (!linkedPath)
|
|
260
|
+
continue;
|
|
261
|
+
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
|
262
|
+
if (resolvedLinkedPath !== path.resolve(entry.source))
|
|
263
|
+
continue;
|
|
264
|
+
if (await removeSkillTarget(target)) {
|
|
265
|
+
await input.onLog("stdout", `[paperclip] Revoked legacy ACPX Codex skill "${entry.runtimeName}" from ${input.skillsHome}\n`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const name of managed) {
|
|
269
|
+
if (desired.has(name) || availableByRuntimeName.has(name))
|
|
270
|
+
continue;
|
|
271
|
+
if (await removeSkillTarget(path.join(input.skillsHome, name))) {
|
|
272
|
+
await input.onLog("stdout", `[paperclip] Revoked unavailable ACPX Codex skill "${name}" from ${input.skillsHome}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function prepareCodexSkillRuntime(input) {
|
|
277
|
+
const envConfig = parseObject(input.config.env);
|
|
278
|
+
const configuredCodexHome = typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
|
279
|
+
? path.resolve(envConfig.CODEX_HOME.trim())
|
|
280
|
+
: null;
|
|
281
|
+
const sourceCodexHome = typeof process.env.CODEX_HOME === "string" && process.env.CODEX_HOME.trim().length > 0
|
|
282
|
+
? path.resolve(process.env.CODEX_HOME.trim())
|
|
283
|
+
: path.join(os.homedir(), ".codex");
|
|
284
|
+
const managedCodexHome = resolveManagedCodexHomeDir(input.companyId);
|
|
285
|
+
const effectiveCodexHome = configuredCodexHome ??
|
|
286
|
+
await prepareManagedCodexHome({
|
|
287
|
+
companyId: input.companyId,
|
|
288
|
+
sourceHome: sourceCodexHome,
|
|
289
|
+
targetHome: managedCodexHome,
|
|
290
|
+
onLog: input.onLog,
|
|
291
|
+
});
|
|
292
|
+
const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config);
|
|
293
|
+
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "codex" });
|
|
294
|
+
const skillsHome = path.join(effectiveCodexHome, "skills");
|
|
295
|
+
await fs.mkdir(skillsHome, { recursive: true });
|
|
296
|
+
await reconcileManagedCodexSkills({
|
|
297
|
+
skillsHome,
|
|
298
|
+
allSkills,
|
|
299
|
+
selectedSkills,
|
|
300
|
+
onLog: input.onLog,
|
|
301
|
+
});
|
|
302
|
+
for (const entry of selectedSkills) {
|
|
303
|
+
const target = path.join(skillsHome, entry.runtimeName);
|
|
304
|
+
try {
|
|
305
|
+
const result = await materializePaperclipSkillCopy(entry.source, target);
|
|
306
|
+
if (result.skippedSymlinks.length > 0) {
|
|
307
|
+
await input.onLog("stdout", `[paperclip] Materialized ACPX Codex skill "${entry.runtimeName}" into ${skillsHome} and skipped ${result.skippedSymlinks.length} symlink(s).\n`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
await input.onLog("stderr", `[paperclip] Failed to inject ACPX Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
await writeManagedCodexSkillsManifest(skillsHome, selectedSkills.map((entry) => entry.runtimeName));
|
|
315
|
+
input.env.CODEX_HOME = effectiveCodexHome;
|
|
316
|
+
return {
|
|
317
|
+
identity: {
|
|
318
|
+
mode: "codex",
|
|
319
|
+
skillSetKey,
|
|
320
|
+
desiredSkillNames,
|
|
321
|
+
selectedSkills: selectedSkills.map((entry) => entry.runtimeName).sort(),
|
|
322
|
+
codexHome: effectiveCodexHome,
|
|
323
|
+
skillsHome,
|
|
324
|
+
},
|
|
325
|
+
commandNotes: [`Prepared ACPX Codex skill home at ${skillsHome}.`],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function normalizeMode(config) {
|
|
329
|
+
return asString(config.mode, DEFAULT_ACPX_LOCAL_MODE) === "oneshot" ? "oneshot" : "persistent";
|
|
330
|
+
}
|
|
331
|
+
function normalizePermissionMode(config) {
|
|
332
|
+
const value = asString(config.permissionMode, DEFAULT_ACPX_LOCAL_PERMISSION_MODE).trim();
|
|
333
|
+
if (value === "approve-reads" || value === "deny-all")
|
|
334
|
+
return value;
|
|
335
|
+
if (value === "default")
|
|
336
|
+
return "approve-reads";
|
|
337
|
+
return "approve-all";
|
|
338
|
+
}
|
|
339
|
+
function normalizeNonInteractivePermissions(config) {
|
|
340
|
+
return asString(config.nonInteractivePermissions, DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS) === "fail"
|
|
341
|
+
? "fail"
|
|
342
|
+
: "deny";
|
|
343
|
+
}
|
|
344
|
+
function isCompatibleSession(params, runtime) {
|
|
345
|
+
if (asString(params.configFingerprint, "") !== runtime.fingerprint)
|
|
346
|
+
return false;
|
|
347
|
+
if (asString(params.sessionKey, "") !== runtime.sessionKey)
|
|
348
|
+
return false;
|
|
349
|
+
if (asString(params.agent, "") !== runtime.acpxAgent)
|
|
350
|
+
return false;
|
|
351
|
+
if (asString(params.mode, "") !== runtime.mode)
|
|
352
|
+
return false;
|
|
353
|
+
const savedCwd = asString(params.cwd, "");
|
|
354
|
+
if (!savedCwd || path.resolve(savedCwd) !== path.resolve(runtime.cwd))
|
|
355
|
+
return false;
|
|
356
|
+
const savedRemote = parseObject(params.remoteExecution);
|
|
357
|
+
return stableJson(savedRemote) === stableJson(runtime.remoteExecutionIdentity ?? {});
|
|
358
|
+
}
|
|
359
|
+
function buildSessionParams(input) {
|
|
360
|
+
const { prepared, handle } = input;
|
|
361
|
+
return {
|
|
362
|
+
sessionKey: prepared.sessionKey,
|
|
363
|
+
runtimeSessionName: handle.runtimeSessionName,
|
|
364
|
+
acpxRecordId: handle.acpxRecordId,
|
|
365
|
+
acpSessionId: handle.backendSessionId,
|
|
366
|
+
agentSessionId: handle.agentSessionId,
|
|
367
|
+
agent: prepared.acpxAgent,
|
|
368
|
+
cwd: prepared.cwd,
|
|
369
|
+
mode: prepared.mode,
|
|
370
|
+
stateDir: prepared.stateDir,
|
|
371
|
+
configFingerprint: prepared.fingerprint,
|
|
372
|
+
skills: prepared.skillsIdentity,
|
|
373
|
+
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
|
|
374
|
+
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
|
|
375
|
+
...(prepared.workspaceRepoRef ? { repoRef: prepared.workspaceRepoRef } : {}),
|
|
376
|
+
...(prepared.remoteExecutionIdentity ? { remoteExecution: prepared.remoteExecutionIdentity } : {}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
async function writeAgentWrapper(input) {
|
|
380
|
+
const wrappersDir = path.join(input.stateDir, "wrappers");
|
|
381
|
+
await fs.mkdir(wrappersDir, { recursive: true });
|
|
382
|
+
const envLines = Object.entries(input.env)
|
|
383
|
+
.filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
384
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
385
|
+
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
386
|
+
const wrapperHash = shortHash({
|
|
387
|
+
agent: input.acpxAgent,
|
|
388
|
+
command: input.agentCommandShell,
|
|
389
|
+
env: envLines,
|
|
390
|
+
});
|
|
391
|
+
const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`);
|
|
392
|
+
const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`);
|
|
393
|
+
const script = [
|
|
394
|
+
"#!/usr/bin/env bash",
|
|
395
|
+
"set -euo pipefail",
|
|
396
|
+
`env_file=${shellQuote(envFilePath)}`,
|
|
397
|
+
"if [[ -f \"$env_file\" ]]; then",
|
|
398
|
+
" set -a",
|
|
399
|
+
" source \"$env_file\"",
|
|
400
|
+
" set +a",
|
|
401
|
+
"fi",
|
|
402
|
+
`exec ${input.agentCommandShell} "$@"`,
|
|
403
|
+
"",
|
|
404
|
+
].join("\n");
|
|
405
|
+
await writeFileAtomically({
|
|
406
|
+
target: envFilePath,
|
|
407
|
+
contents: `${envLines.join("\n")}\n`,
|
|
408
|
+
mode: 0o600,
|
|
409
|
+
});
|
|
410
|
+
await writeFileAtomically({
|
|
411
|
+
target: wrapperPath,
|
|
412
|
+
contents: script,
|
|
413
|
+
mode: 0o700,
|
|
414
|
+
});
|
|
415
|
+
await cleanupStaleAgentWrappers({
|
|
416
|
+
wrappersDir,
|
|
417
|
+
currentFileNames: new Set([path.basename(wrapperPath), path.basename(envFilePath)]),
|
|
418
|
+
});
|
|
419
|
+
return { wrapperPath, envFilePath };
|
|
420
|
+
}
|
|
421
|
+
async function cleanupStaleAgentWrappers(input) {
|
|
422
|
+
const wrappers = await fs.readdir(input.wrappersDir).catch(() => []);
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
await Promise.all(wrappers.map(async (name) => {
|
|
425
|
+
const isManagedWrapperFile = name.endsWith(".sh") || name.endsWith(".env");
|
|
426
|
+
if (!isManagedWrapperFile || input.currentFileNames.has(name))
|
|
427
|
+
return;
|
|
428
|
+
const wrapperPath = path.join(input.wrappersDir, name);
|
|
429
|
+
const stats = await fs.stat(wrapperPath).catch(() => null);
|
|
430
|
+
if (!stats || now - stats.mtimeMs < WRAPPER_CLEANUP_RETENTION_MS)
|
|
431
|
+
return;
|
|
432
|
+
await fs.rm(wrapperPath, { force: true });
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
async function buildRuntime(input) {
|
|
436
|
+
const { runId, agent, config, context, authToken } = input.ctx;
|
|
437
|
+
const workspaceContext = parseObject(context.paperclipWorkspace);
|
|
438
|
+
const workspaceCwd = asString(workspaceContext.cwd, "");
|
|
439
|
+
const workspaceSource = asString(workspaceContext.source, "");
|
|
440
|
+
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
|
441
|
+
const workspaceId = asString(workspaceContext.workspaceId, "");
|
|
442
|
+
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
|
443
|
+
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
|
444
|
+
const workspaceBranch = asString(workspaceContext.branchName, "");
|
|
445
|
+
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
|
446
|
+
const agentHome = asString(workspaceContext.agentHome, "");
|
|
447
|
+
const configuredCwd = asString(config.cwd, "");
|
|
448
|
+
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
449
|
+
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
450
|
+
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
451
|
+
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
452
|
+
const acpxAgent = normalizeAgent(config);
|
|
453
|
+
const mode = normalizeMode(config);
|
|
454
|
+
const permissionMode = normalizePermissionMode(config);
|
|
455
|
+
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
|
|
456
|
+
const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
|
|
457
|
+
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
|
|
458
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
459
|
+
const envConfig = parseObject(config.env);
|
|
460
|
+
const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
|
461
|
+
const env = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId };
|
|
462
|
+
const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim()) ||
|
|
463
|
+
(typeof context.issueId === "string" && context.issueId.trim()) ||
|
|
464
|
+
"";
|
|
465
|
+
const wakeReason = typeof context.wakeReason === "string" ? context.wakeReason.trim() : "";
|
|
466
|
+
const wakeCommentId = (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim()) ||
|
|
467
|
+
(typeof context.commentId === "string" && context.commentId.trim()) ||
|
|
468
|
+
"";
|
|
469
|
+
const approvalId = typeof context.approvalId === "string" ? context.approvalId.trim() : "";
|
|
470
|
+
const approvalStatus = typeof context.approvalStatus === "string" ? context.approvalStatus.trim() : "";
|
|
471
|
+
const linkedIssueIds = Array.isArray(context.issueIds)
|
|
472
|
+
? context.issueIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
473
|
+
: [];
|
|
474
|
+
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
|
475
|
+
if (wakeTaskId)
|
|
476
|
+
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
|
477
|
+
if (wakeReason)
|
|
478
|
+
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
|
479
|
+
if (wakeCommentId)
|
|
480
|
+
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
|
481
|
+
if (approvalId)
|
|
482
|
+
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
|
483
|
+
if (approvalStatus)
|
|
484
|
+
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
|
485
|
+
if (linkedIssueIds.length > 0)
|
|
486
|
+
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
|
487
|
+
if (wakePayloadJson)
|
|
488
|
+
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
|
489
|
+
applyPaperclipWorkspaceEnv(env, {
|
|
490
|
+
workspaceCwd: effectiveWorkspaceCwd,
|
|
491
|
+
workspaceSource,
|
|
492
|
+
workspaceStrategy,
|
|
493
|
+
workspaceId,
|
|
494
|
+
workspaceRepoUrl,
|
|
495
|
+
workspaceRepoRef,
|
|
496
|
+
workspaceBranch,
|
|
497
|
+
workspaceWorktreePath,
|
|
498
|
+
agentHome,
|
|
499
|
+
});
|
|
500
|
+
for (const [key, value] of Object.entries(envConfig)) {
|
|
501
|
+
if (typeof value === "string")
|
|
502
|
+
env[key] = value;
|
|
503
|
+
}
|
|
504
|
+
if (!hasExplicitApiKey && authToken)
|
|
505
|
+
env.PAPERCLIP_API_KEY = authToken;
|
|
506
|
+
let skillPromptInstructions = "";
|
|
507
|
+
let skillsIdentity = { mode: "unsupported" };
|
|
508
|
+
const skillCommandNotes = [];
|
|
509
|
+
if (acpxAgent === "claude") {
|
|
510
|
+
const preparedSkills = await prepareClaudeSkillRuntime({
|
|
511
|
+
stateDir,
|
|
512
|
+
config,
|
|
513
|
+
onLog: input.ctx.onLog,
|
|
514
|
+
});
|
|
515
|
+
skillPromptInstructions = preparedSkills.promptInstructions;
|
|
516
|
+
skillsIdentity = preparedSkills.identity;
|
|
517
|
+
skillCommandNotes.push(...preparedSkills.commandNotes);
|
|
518
|
+
}
|
|
519
|
+
else if (acpxAgent === "codex") {
|
|
520
|
+
const preparedSkills = await prepareCodexSkillRuntime({
|
|
521
|
+
companyId: agent.companyId,
|
|
522
|
+
config,
|
|
523
|
+
env,
|
|
524
|
+
onLog: input.ctx.onLog,
|
|
525
|
+
});
|
|
526
|
+
skillsIdentity = preparedSkills.identity;
|
|
527
|
+
skillCommandNotes.push(...preparedSkills.commandNotes);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
const desired = resolvePaperclipDesiredSkillNames(config, await readPaperclipRuntimeSkillEntries(config, __moduleDir));
|
|
531
|
+
skillsIdentity = { mode: "custom_unsupported", desiredSkillNames: desired };
|
|
532
|
+
if (desired.length > 0) {
|
|
533
|
+
skillCommandNotes.push("Selected Paperclip skills are tracked only; ACPX custom commands do not expose a runtime skill contract yet.");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const configuredCommand = asString(config.agentCommand, "").trim();
|
|
537
|
+
const builtInCommand = resolveBuiltInAgentCommand(acpxAgent);
|
|
538
|
+
const agentCommand = configuredCommand || builtInCommand || null;
|
|
539
|
+
const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : "");
|
|
540
|
+
const wrapper = agentCommand
|
|
541
|
+
? await writeAgentWrapper({
|
|
542
|
+
stateDir,
|
|
543
|
+
acpxAgent,
|
|
544
|
+
agentCommandShell,
|
|
545
|
+
env,
|
|
546
|
+
})
|
|
547
|
+
: null;
|
|
548
|
+
const wrapperPath = wrapper?.wrapperPath ?? null;
|
|
549
|
+
const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined;
|
|
550
|
+
const agentRegistry = createAgentRegistry({ overrides });
|
|
551
|
+
const executionTarget = readAdapterExecutionTarget({
|
|
552
|
+
executionTarget: input.ctx.executionTarget,
|
|
553
|
+
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
|
|
554
|
+
});
|
|
555
|
+
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
|
|
556
|
+
const fingerprint = shortHash({
|
|
557
|
+
acpxAgent,
|
|
558
|
+
agentCommand: agentCommand ?? acpxAgent,
|
|
559
|
+
cwd: path.resolve(cwd),
|
|
560
|
+
mode,
|
|
561
|
+
permissionMode,
|
|
562
|
+
nonInteractivePermissions,
|
|
563
|
+
remoteExecutionIdentity,
|
|
564
|
+
skillsIdentity,
|
|
565
|
+
skillPromptInstructions,
|
|
566
|
+
});
|
|
567
|
+
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
|
|
568
|
+
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
|
|
569
|
+
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
570
|
+
const loggedEnv = buildInvocationEnvForLogs(env, {
|
|
571
|
+
runtimeEnv,
|
|
572
|
+
includeRuntimeKeys: ["HOME"],
|
|
573
|
+
resolvedCommand: wrapperPath ?? agentCommand ?? acpxAgent,
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
acpxAgent,
|
|
577
|
+
mode,
|
|
578
|
+
cwd,
|
|
579
|
+
workspaceId,
|
|
580
|
+
workspaceRepoUrl,
|
|
581
|
+
workspaceRepoRef,
|
|
582
|
+
env,
|
|
583
|
+
loggedEnv,
|
|
584
|
+
stateDir,
|
|
585
|
+
permissionMode,
|
|
586
|
+
nonInteractivePermissions,
|
|
587
|
+
timeoutSec,
|
|
588
|
+
sessionKey,
|
|
589
|
+
fingerprint,
|
|
590
|
+
agentCommand,
|
|
591
|
+
agentRegistry,
|
|
592
|
+
remoteExecutionIdentity,
|
|
593
|
+
skillPromptInstructions,
|
|
594
|
+
skillsIdentity: {
|
|
595
|
+
...skillsIdentity,
|
|
596
|
+
commandNotes: skillCommandNotes,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
async function buildPrompt(ctx, resumedSession) {
|
|
601
|
+
const { agent, runId, config, context, onLog } = ctx;
|
|
602
|
+
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
|
603
|
+
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
604
|
+
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
605
|
+
let instructionsPrefix = "";
|
|
606
|
+
const commandNotes = [];
|
|
607
|
+
if (instructionsFilePath) {
|
|
608
|
+
try {
|
|
609
|
+
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
|
610
|
+
instructionsPrefix =
|
|
611
|
+
`${instructionsContents}\n\n` +
|
|
612
|
+
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
613
|
+
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
614
|
+
commandNotes.push(`Loaded agent instructions from ${instructionsFilePath}`, `Prepended instructions + path directive to the ACPX prompt (relative references from ${instructionsDir}).`);
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
618
|
+
await onLog("stderr", `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`);
|
|
619
|
+
commandNotes.push(`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
623
|
+
const templateData = {
|
|
624
|
+
agentId: agent.id,
|
|
625
|
+
companyId: agent.companyId,
|
|
626
|
+
runId,
|
|
627
|
+
company: { id: agent.companyId },
|
|
628
|
+
agent,
|
|
629
|
+
run: { id: runId, source: "on_demand" },
|
|
630
|
+
context,
|
|
631
|
+
};
|
|
632
|
+
const renderedBootstrapPrompt = !resumedSession && bootstrapPromptTemplate.trim().length > 0
|
|
633
|
+
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
634
|
+
: "";
|
|
635
|
+
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession });
|
|
636
|
+
const shouldUseResumeDeltaPrompt = resumedSession && wakePrompt.length > 0;
|
|
637
|
+
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
|
638
|
+
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
|
639
|
+
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
640
|
+
const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim();
|
|
641
|
+
const prompt = joinPromptSections([
|
|
642
|
+
promptInstructionsPrefix,
|
|
643
|
+
renderedBootstrapPrompt,
|
|
644
|
+
wakePrompt,
|
|
645
|
+
sessionHandoffNote,
|
|
646
|
+
taskContextNote,
|
|
647
|
+
renderedPrompt,
|
|
648
|
+
]);
|
|
649
|
+
return {
|
|
650
|
+
prompt,
|
|
651
|
+
commandNotes,
|
|
652
|
+
promptMetrics: {
|
|
653
|
+
promptChars: prompt.length,
|
|
654
|
+
instructionsChars: promptInstructionsPrefix.length,
|
|
655
|
+
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
656
|
+
wakePromptChars: wakePrompt.length,
|
|
657
|
+
sessionHandoffChars: sessionHandoffNote.length,
|
|
658
|
+
taskContextChars: taskContextNote.length,
|
|
659
|
+
heartbeatPromptChars: renderedPrompt.length,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
async function emitAcpxLog(ctx, payload) {
|
|
664
|
+
await ctx.onLog("stdout", `${JSON.stringify(payload)}\n`);
|
|
665
|
+
}
|
|
666
|
+
async function emitRuntimeEvent(ctx, event) {
|
|
667
|
+
if (event.type === "text_delta") {
|
|
668
|
+
await emitAcpxLog(ctx, {
|
|
669
|
+
type: "acpx.text_delta",
|
|
670
|
+
text: event.text,
|
|
671
|
+
channel: event.stream === "thought" ? "thought" : "output",
|
|
672
|
+
tag: event.tag,
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (event.type === "tool_call") {
|
|
677
|
+
await emitAcpxLog(ctx, {
|
|
678
|
+
type: "acpx.tool_call",
|
|
679
|
+
name: event.title ?? "acp_tool",
|
|
680
|
+
toolCallId: event.toolCallId,
|
|
681
|
+
status: event.status,
|
|
682
|
+
text: event.text,
|
|
683
|
+
tag: event.tag,
|
|
684
|
+
});
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (event.type === "status") {
|
|
688
|
+
await emitAcpxLog(ctx, {
|
|
689
|
+
type: "acpx.status",
|
|
690
|
+
text: event.text,
|
|
691
|
+
tag: event.tag,
|
|
692
|
+
used: event.used,
|
|
693
|
+
size: event.size,
|
|
694
|
+
});
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (event.type === "done") {
|
|
698
|
+
await emitAcpxLog(ctx, {
|
|
699
|
+
type: "acpx.result",
|
|
700
|
+
summary: event.stopReason ?? "completed",
|
|
701
|
+
stopReason: event.stopReason,
|
|
702
|
+
});
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (event.type === "error") {
|
|
706
|
+
await emitAcpxLog(ctx, {
|
|
707
|
+
type: "acpx.error",
|
|
708
|
+
message: event.message,
|
|
709
|
+
code: event.code,
|
|
710
|
+
retryable: event.retryable,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function resultErrorMessage(result) {
|
|
715
|
+
if (result.status !== "failed")
|
|
716
|
+
return null;
|
|
717
|
+
return result.error.message;
|
|
718
|
+
}
|
|
719
|
+
function classifyError(err) {
|
|
720
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
721
|
+
const maybeCode = err && typeof err === "object" && typeof err.code === "string"
|
|
722
|
+
? err.code
|
|
723
|
+
: null;
|
|
724
|
+
const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
|
|
725
|
+
const lower = message.toLowerCase();
|
|
726
|
+
const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential");
|
|
727
|
+
if (authLike) {
|
|
728
|
+
return {
|
|
729
|
+
errorCode: "acpx_auth_required",
|
|
730
|
+
errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
if (acpCode) {
|
|
734
|
+
return {
|
|
735
|
+
errorCode: "acpx_protocol_error",
|
|
736
|
+
errorMeta: { category: "protocol", acpCode },
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
errorCode: "acpx_runtime_error",
|
|
741
|
+
errorMeta: { category: "runtime" },
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
function isResumeFailure(err) {
|
|
745
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
746
|
+
return /resume|load|not found|no session|unknown session|conversation/i.test(message);
|
|
747
|
+
}
|
|
748
|
+
async function cleanupIdleHandles(input) {
|
|
749
|
+
const stale = [];
|
|
750
|
+
for (const entry of input.handles.entries()) {
|
|
751
|
+
if (input.now - entry[1].lastUsedAt >= input.idleMs)
|
|
752
|
+
stale.push(entry);
|
|
753
|
+
}
|
|
754
|
+
for (const [key, entry] of stale) {
|
|
755
|
+
input.handles.delete(key);
|
|
756
|
+
await entry.runtime.close({
|
|
757
|
+
handle: entry.handle,
|
|
758
|
+
reason: "paperclip idle cleanup",
|
|
759
|
+
discardPersistentState: false,
|
|
760
|
+
}).catch(() => { });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
function warmHandleMatches(entry, runtime, handle) {
|
|
764
|
+
return entry?.runtime === runtime && entry.handle === handle;
|
|
765
|
+
}
|
|
766
|
+
export function createAcpxLocalExecutor(deps = {}) {
|
|
767
|
+
const createRuntime = deps.createRuntime ?? createAcpRuntime;
|
|
768
|
+
const now = deps.now ?? (() => Date.now());
|
|
769
|
+
const warmHandles = deps.warmHandles ?? defaultWarmHandles;
|
|
770
|
+
return async function executeAcpxLocal(ctx) {
|
|
771
|
+
const prepared = await buildRuntime({ ctx });
|
|
772
|
+
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_WARM_HANDLE_IDLE_MS);
|
|
773
|
+
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
|
|
774
|
+
const previousParams = parseObject(ctx.runtime.sessionParams);
|
|
775
|
+
const canResume = isCompatibleSession(previousParams, prepared);
|
|
776
|
+
const resumeSessionId = canResume ? asString(previousParams.acpSessionId, "") || undefined : undefined;
|
|
777
|
+
const cached = canResume ? warmHandles.get(prepared.sessionKey) : undefined;
|
|
778
|
+
const runtimeOptions = {
|
|
779
|
+
cwd: prepared.cwd,
|
|
780
|
+
sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }),
|
|
781
|
+
agentRegistry: prepared.agentRegistry,
|
|
782
|
+
permissionMode: prepared.permissionMode,
|
|
783
|
+
nonInteractivePermissions: prepared.nonInteractivePermissions,
|
|
784
|
+
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
|
|
785
|
+
};
|
|
786
|
+
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
|
|
787
|
+
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
|
788
|
+
await ctx.onLog("stdout", `[paperclip] ACPX session "${asString(previousParams.runtimeSessionName, "")}" does not match the current agent/cwd/mode/runtime identity; starting fresh in "${prepared.cwd}".\n`);
|
|
789
|
+
}
|
|
790
|
+
let handle = cached?.handle ?? null;
|
|
791
|
+
let resumedSession = Boolean(handle ?? resumeSessionId);
|
|
792
|
+
let clearSession = false;
|
|
793
|
+
try {
|
|
794
|
+
if (!handle) {
|
|
795
|
+
try {
|
|
796
|
+
handle = await runtime.ensureSession({
|
|
797
|
+
sessionKey: prepared.sessionKey,
|
|
798
|
+
agent: prepared.acpxAgent,
|
|
799
|
+
mode: prepared.mode,
|
|
800
|
+
cwd: prepared.cwd,
|
|
801
|
+
resumeSessionId,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
if (!resumeSessionId || !isResumeFailure(err))
|
|
806
|
+
throw err;
|
|
807
|
+
clearSession = true;
|
|
808
|
+
resumedSession = false;
|
|
809
|
+
await ctx.onLog("stdout", `[paperclip] ACPX resume session "${resumeSessionId}" is unavailable; retrying with a fresh session.\n`);
|
|
810
|
+
handle = await runtime.ensureSession({
|
|
811
|
+
sessionKey: prepared.sessionKey,
|
|
812
|
+
agent: prepared.acpxAgent,
|
|
813
|
+
mode: prepared.mode,
|
|
814
|
+
cwd: prepared.cwd,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
const classified = classifyError(err);
|
|
821
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
822
|
+
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
|
823
|
+
return {
|
|
824
|
+
exitCode: 1,
|
|
825
|
+
signal: null,
|
|
826
|
+
timedOut: false,
|
|
827
|
+
errorMessage: message,
|
|
828
|
+
...classified,
|
|
829
|
+
provider: "acpx",
|
|
830
|
+
model: null,
|
|
831
|
+
clearSession,
|
|
832
|
+
resultJson: { phase: "ensure_session" },
|
|
833
|
+
summary: message,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
if (!handle) {
|
|
837
|
+
return {
|
|
838
|
+
exitCode: 1,
|
|
839
|
+
signal: null,
|
|
840
|
+
timedOut: false,
|
|
841
|
+
errorMessage: "ACPX did not return a runtime session handle.",
|
|
842
|
+
errorCode: "acpx_runtime_error",
|
|
843
|
+
provider: "acpx",
|
|
844
|
+
model: null,
|
|
845
|
+
resultJson: { phase: "ensure_session" },
|
|
846
|
+
summary: "ACPX did not return a runtime session handle.",
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
const sessionHandle = handle;
|
|
850
|
+
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
|
|
851
|
+
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
|
852
|
+
await emitAcpxLog(ctx, {
|
|
853
|
+
type: "acpx.session",
|
|
854
|
+
agent: prepared.acpxAgent,
|
|
855
|
+
sessionId: sessionHandle.backendSessionId,
|
|
856
|
+
acpSessionId: sessionHandle.backendSessionId,
|
|
857
|
+
agentSessionId: sessionHandle.agentSessionId,
|
|
858
|
+
runtimeSessionName: sessionHandle.runtimeSessionName,
|
|
859
|
+
mode: prepared.mode,
|
|
860
|
+
permissionMode: prepared.permissionMode,
|
|
861
|
+
});
|
|
862
|
+
if (ctx.onMeta) {
|
|
863
|
+
await ctx.onMeta({
|
|
864
|
+
adapterType: "acpx_local",
|
|
865
|
+
command: prepared.agentCommand ?? prepared.acpxAgent,
|
|
866
|
+
cwd: prepared.cwd,
|
|
867
|
+
commandNotes: [
|
|
868
|
+
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
|
869
|
+
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
|
870
|
+
...(Array.isArray(prepared.skillsIdentity.commandNotes)
|
|
871
|
+
? prepared.skillsIdentity.commandNotes.filter((note) => typeof note === "string")
|
|
872
|
+
: []),
|
|
873
|
+
...commandNotes,
|
|
874
|
+
],
|
|
875
|
+
env: prepared.loggedEnv,
|
|
876
|
+
prompt: runPrompt,
|
|
877
|
+
promptMetrics,
|
|
878
|
+
context: ctx.context,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
let cancelActiveTurn = null;
|
|
882
|
+
let controller = null;
|
|
883
|
+
let timeout = null;
|
|
884
|
+
let timedOut = false;
|
|
885
|
+
const textParts = [];
|
|
886
|
+
try {
|
|
887
|
+
const timeoutMs = prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined;
|
|
888
|
+
controller = new AbortController();
|
|
889
|
+
if (timeoutMs) {
|
|
890
|
+
timeout = setTimeout(() => {
|
|
891
|
+
timedOut = true;
|
|
892
|
+
controller?.abort();
|
|
893
|
+
void cancelActiveTurn?.(`Timed out after ${prepared.timeoutSec}s`).catch(() => { });
|
|
894
|
+
}, timeoutMs);
|
|
895
|
+
}
|
|
896
|
+
const turn = runtime.startTurn({
|
|
897
|
+
handle: sessionHandle,
|
|
898
|
+
text: runPrompt,
|
|
899
|
+
mode: "prompt",
|
|
900
|
+
requestId: ctx.runId,
|
|
901
|
+
timeoutMs,
|
|
902
|
+
signal: controller?.signal,
|
|
903
|
+
});
|
|
904
|
+
cancelActiveTurn = async (reason) => {
|
|
905
|
+
await turn.cancel({ reason });
|
|
906
|
+
};
|
|
907
|
+
for await (const event of turn.events) {
|
|
908
|
+
if (event.type === "text_delta")
|
|
909
|
+
textParts.push(event.text);
|
|
910
|
+
await emitRuntimeEvent(ctx, event);
|
|
911
|
+
}
|
|
912
|
+
const terminal = await turn.result;
|
|
913
|
+
if (timeout)
|
|
914
|
+
clearTimeout(timeout);
|
|
915
|
+
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
|
|
916
|
+
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
|
|
917
|
+
warmHandles.delete(prepared.sessionKey);
|
|
918
|
+
}
|
|
919
|
+
await runtime.close({
|
|
920
|
+
handle: sessionHandle,
|
|
921
|
+
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
|
922
|
+
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
|
923
|
+
}).catch(() => { });
|
|
924
|
+
}
|
|
925
|
+
else if (prepared.mode === "persistent") {
|
|
926
|
+
const existing = warmHandles.get(prepared.sessionKey);
|
|
927
|
+
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
|
|
928
|
+
await runtime.close({
|
|
929
|
+
handle: sessionHandle,
|
|
930
|
+
reason: "paperclip duplicate warm handle cleanup",
|
|
931
|
+
discardPersistentState: false,
|
|
932
|
+
}).catch(() => { });
|
|
933
|
+
}
|
|
934
|
+
else {
|
|
935
|
+
warmHandles.set(prepared.sessionKey, {
|
|
936
|
+
runtime,
|
|
937
|
+
handle: sessionHandle,
|
|
938
|
+
fingerprint: prepared.fingerprint,
|
|
939
|
+
lastUsedAt: now(),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const errorMessage = timedOut
|
|
944
|
+
? `Timed out after ${prepared.timeoutSec}s`
|
|
945
|
+
: resultErrorMessage(terminal);
|
|
946
|
+
const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason;
|
|
947
|
+
await emitAcpxLog(ctx, {
|
|
948
|
+
type: terminal.status === "completed" ? "acpx.result" : "acpx.error",
|
|
949
|
+
summary: terminal.status,
|
|
950
|
+
stopReason: terminalStopReason,
|
|
951
|
+
message: errorMessage,
|
|
952
|
+
});
|
|
953
|
+
return {
|
|
954
|
+
exitCode: terminal.status === "completed" ? 0 : 1,
|
|
955
|
+
signal: timedOut ? "SIGTERM" : null,
|
|
956
|
+
timedOut,
|
|
957
|
+
errorMessage,
|
|
958
|
+
errorCode: terminal.status === "failed" ? "acpx_turn_failed" : timedOut ? "acpx_timeout" : null,
|
|
959
|
+
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
960
|
+
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
|
961
|
+
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
962
|
+
provider: "acpx",
|
|
963
|
+
model: null,
|
|
964
|
+
billingType: "unknown",
|
|
965
|
+
costUsd: null,
|
|
966
|
+
resultJson: {
|
|
967
|
+
status: terminal.status,
|
|
968
|
+
stopReason: terminalStopReason,
|
|
969
|
+
permissionMode: prepared.permissionMode,
|
|
970
|
+
mode: prepared.mode,
|
|
971
|
+
},
|
|
972
|
+
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
|
|
973
|
+
clearSession,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
if (timeout)
|
|
978
|
+
clearTimeout(timeout);
|
|
979
|
+
const classified = classifyError(err);
|
|
980
|
+
const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err);
|
|
981
|
+
const cancel = cancelActiveTurn;
|
|
982
|
+
if (cancel)
|
|
983
|
+
await cancel(message).catch(() => { });
|
|
984
|
+
await runtime.close({
|
|
985
|
+
handle: sessionHandle,
|
|
986
|
+
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
|
|
987
|
+
discardPersistentState: timedOut,
|
|
988
|
+
}).catch(() => { });
|
|
989
|
+
if (warmHandleMatches(warmHandles.get(prepared.sessionKey), runtime, sessionHandle)) {
|
|
990
|
+
warmHandles.delete(prepared.sessionKey);
|
|
991
|
+
}
|
|
992
|
+
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
|
|
993
|
+
return {
|
|
994
|
+
exitCode: 1,
|
|
995
|
+
signal: timedOut ? "SIGTERM" : null,
|
|
996
|
+
timedOut,
|
|
997
|
+
errorMessage: message,
|
|
998
|
+
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
|
|
999
|
+
errorMeta: classified.errorMeta,
|
|
1000
|
+
provider: "acpx",
|
|
1001
|
+
model: null,
|
|
1002
|
+
clearSession: clearSession || timedOut,
|
|
1003
|
+
resultJson: { phase: "turn" },
|
|
1004
|
+
summary: message,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
export const execute = createAcpxLocalExecutor();
|
|
1010
|
+
//# sourceMappingURL=execute.js.map
|