merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapter-utils"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/*.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferOpenAiCompatibleBiller } from "./billing.js";
|
||||
|
||||
describe("inferOpenAiCompatibleBiller", () => {
|
||||
it("returns openrouter when OPENROUTER_API_KEY is present", () => {
|
||||
expect(
|
||||
inferOpenAiCompatibleBiller({ OPENROUTER_API_KEY: "sk-or-123" } as NodeJS.ProcessEnv, "openai"),
|
||||
).toBe("openrouter");
|
||||
});
|
||||
|
||||
it("returns openrouter when OPENAI_BASE_URL points at OpenRouter", () => {
|
||||
expect(
|
||||
inferOpenAiCompatibleBiller(
|
||||
{ OPENAI_BASE_URL: "https://openrouter.ai/api/v1" } as NodeJS.ProcessEnv,
|
||||
"openai",
|
||||
),
|
||||
).toBe("openrouter");
|
||||
});
|
||||
|
||||
it("returns fallback when no OpenRouter markers are present", () => {
|
||||
expect(
|
||||
inferOpenAiCompatibleBiller(
|
||||
{ OPENAI_BASE_URL: "https://api.openai.com/v1" } as NodeJS.ProcessEnv,
|
||||
"openai",
|
||||
),
|
||||
).toBe("openai");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
function readEnv(env: NodeJS.ProcessEnv, key: string): string | null {
|
||||
const value = env[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function inferOpenAiCompatibleBiller(
|
||||
env: NodeJS.ProcessEnv,
|
||||
fallback: string | null = "openai",
|
||||
): string | null {
|
||||
const explicitOpenRouterKey = readEnv(env, "OPENROUTER_API_KEY");
|
||||
if (explicitOpenRouterKey) return "openrouter";
|
||||
|
||||
const baseUrl =
|
||||
readEnv(env, "OPENAI_BASE_URL") ??
|
||||
readEnv(env, "OPENAI_API_BASE") ??
|
||||
readEnv(env, "OPENAI_API_BASE_URL");
|
||||
if (baseUrl && /openrouter\.ai/i.test(baseUrl)) return "openrouter";
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type {
|
||||
AdapterAgent,
|
||||
AdapterRuntime,
|
||||
UsageSummary,
|
||||
AdapterBillingType,
|
||||
AdapterRuntimeServiceReport,
|
||||
AdapterExecutionResult,
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterEnvironmentCheckLevel,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestStatus,
|
||||
AdapterEnvironmentTestResult,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterSkillSyncMode,
|
||||
AdapterSkillState,
|
||||
AdapterSkillOrigin,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
AdapterSkillContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
HireApprovedPayload,
|
||||
HireApprovedHookResult,
|
||||
ServerAdapterModule,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
CLIAdapterModule,
|
||||
CreateConfigValues,
|
||||
} from "./types.js";
|
||||
export type {
|
||||
SessionCompactionPolicy,
|
||||
NativeContextManagement,
|
||||
AdapterSessionManagement,
|
||||
ResolvedSessionCompactionPolicy,
|
||||
} from "./session-compaction.js";
|
||||
export {
|
||||
ADAPTER_SESSION_MANAGEMENT,
|
||||
LEGACY_SESSIONED_ADAPTER_TYPES,
|
||||
getAdapterSessionManagement,
|
||||
readSessionCompactionOverride,
|
||||
resolveSessionCompactionPolicy,
|
||||
hasSessionCompactionThresholds,
|
||||
} from "./session-compaction.js";
|
||||
export {
|
||||
REDACTED_HOME_PATH_USER,
|
||||
redactHomePathUserSegments,
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
export { inferOpenAiCompatibleBiller } from "./billing.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { TranscriptEntry } from "./types.js";
|
||||
|
||||
export const REDACTED_HOME_PATH_USER = "*";
|
||||
|
||||
export interface HomePathRedactionOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function maskHomePathUserSegment(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return REDACTED_HOME_PATH_USER;
|
||||
return `${trimmed[0]}${"*".repeat(Math.max(1, Array.from(trimmed).length - 1))}`;
|
||||
}
|
||||
|
||||
const HOME_PATH_PATTERNS = [
|
||||
{
|
||||
regex: /\/Users\/([^/\\\s]+)/g,
|
||||
replace: (_match: string, user: string) => `/Users/${maskHomePathUserSegment(user)}`,
|
||||
},
|
||||
{
|
||||
regex: /\/home\/([^/\\\s]+)/g,
|
||||
replace: (_match: string, user: string) => `/home/${maskHomePathUserSegment(user)}`,
|
||||
},
|
||||
{
|
||||
regex: /([A-Za-z]:\\Users\\)([^\\/\s]+)/g,
|
||||
replace: (_match: string, prefix: string, user: string) => `${prefix}${maskHomePathUserSegment(user)}`,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegments(text: string, opts?: HomePathRedactionOptions): string {
|
||||
if (opts?.enabled === false) return text;
|
||||
let result = text;
|
||||
for (const pattern of HOME_PATH_PATTERNS) {
|
||||
result = result.replace(pattern.regex, pattern.replace);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function redactHomePathUserSegmentsInValue<T>(value: T, opts?: HomePathRedactionOptions): T {
|
||||
if (typeof value === "string") {
|
||||
return redactHomePathUserSegments(value, opts) as T;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redactHomePathUserSegmentsInValue(entry, opts)) as T;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
redacted[key] = redactHomePathUserSegmentsInValue(entry, opts);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
|
||||
export function redactTranscriptEntryPaths(entry: TranscriptEntry, opts?: HomePathRedactionOptions): TranscriptEntry {
|
||||
switch (entry.kind) {
|
||||
case "assistant":
|
||||
case "thinking":
|
||||
case "user":
|
||||
case "stderr":
|
||||
case "system":
|
||||
case "stdout":
|
||||
return { ...entry, text: redactHomePathUserSegments(entry.text, opts) };
|
||||
case "tool_call":
|
||||
return {
|
||||
...entry,
|
||||
name: redactHomePathUserSegments(entry.name, opts),
|
||||
input: redactHomePathUserSegmentsInValue(entry.input, opts),
|
||||
};
|
||||
case "tool_result":
|
||||
return { ...entry, content: redactHomePathUserSegments(entry.content, opts) };
|
||||
case "init":
|
||||
return {
|
||||
...entry,
|
||||
model: redactHomePathUserSegments(entry.model, opts),
|
||||
sessionId: redactHomePathUserSegments(entry.sessionId, opts),
|
||||
};
|
||||
case "result":
|
||||
return {
|
||||
...entry,
|
||||
text: redactHomePathUserSegments(entry.text, opts),
|
||||
subtype: redactHomePathUserSegments(entry.subtype, opts),
|
||||
errors: entry.errors.map((error) => redactHomePathUserSegments(error, opts)),
|
||||
};
|
||||
default:
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,841 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { constants as fsConstants, promises as fs, type Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
export interface RunProcessResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
pid: number | null;
|
||||
startedAt: string | null;
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
child: ChildProcess;
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
event: "close",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): ChildProcess;
|
||||
};
|
||||
|
||||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||
"../../skills",
|
||||
"../../../../../skills",
|
||||
];
|
||||
|
||||
export interface PaperclipSkillEntry {
|
||||
key: string;
|
||||
runtimeName: string;
|
||||
source: string;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
}
|
||||
|
||||
export interface InstalledSkillTarget {
|
||||
targetPath: string | null;
|
||||
kind: "symlink" | "directory" | "file";
|
||||
}
|
||||
|
||||
interface PersistentSkillSnapshotOptions {
|
||||
adapterType: string;
|
||||
availableEntries: PaperclipSkillEntry[];
|
||||
desiredSkills: string[];
|
||||
installed: Map<string, InstalledSkillTarget>;
|
||||
skillsHome: string;
|
||||
locationLabel?: string | null;
|
||||
installedDetail?: string | null;
|
||||
missingDetail: string;
|
||||
externalConflictDetail: string;
|
||||
externalDetail: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
function normalizePathSlashes(value: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function isMaintainerOnlySkillTarget(candidate: string): boolean {
|
||||
return normalizePathSlashes(candidate).includes("/.agents/skills/");
|
||||
}
|
||||
|
||||
function skillLocationLabel(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
||||
AdapterSkillEntry,
|
||||
"origin" | "originLabel" | "readOnly"
|
||||
> {
|
||||
if (entry.required) {
|
||||
return {
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by Paperclip",
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInstalledEntryTarget(
|
||||
skillsHome: string,
|
||||
entryName: string,
|
||||
dirent: Dirent,
|
||||
linkedPath: string | null,
|
||||
): InstalledSkillTarget {
|
||||
const fullPath = path.join(skillsHome, entryName);
|
||||
if (dirent.isSymbolicLink()) {
|
||||
return {
|
||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||
kind: "symlink",
|
||||
};
|
||||
}
|
||||
if (dirent.isDirectory()) {
|
||||
return { targetPath: fullPath, kind: "directory" };
|
||||
}
|
||||
return { targetPath: fullPath, kind: "file" };
|
||||
}
|
||||
|
||||
export function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asString(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export function parseJson(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
|
||||
const combined = prev + chunk;
|
||||
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
|
||||
}
|
||||
|
||||
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
|
||||
const parts = dottedPath.split(".");
|
||||
let cursor: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
|
||||
return "";
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
if (cursor === null || cursor === undefined) return "";
|
||||
if (typeof cursor === "string") return cursor;
|
||||
if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor);
|
||||
|
||||
try {
|
||||
return JSON.stringify(cursor);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTemplate(template: string, data: Record<string, unknown>) {
|
||||
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
|
||||
}
|
||||
|
||||
export function joinPromptSections(
|
||||
sections: Array<string | null | undefined>,
|
||||
separator = "\n\n",
|
||||
) {
|
||||
return sections
|
||||
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record<string, string> {
|
||||
const resolveHostForUrl = (rawHost: string): string => {
|
||||
const host = rawHost.trim();
|
||||
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
||||
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
||||
return host;
|
||||
};
|
||||
const vars: Record<string, string> = {
|
||||
PAPERCLIP_AGENT_ID: agent.id,
|
||||
PAPERCLIP_COMPANY_ID: agent.companyId,
|
||||
};
|
||||
const runtimeHost = resolveHostForUrl(
|
||||
process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
||||
);
|
||||
const runtimePort = process.env.PAPERCLIP_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
||||
const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
|
||||
vars.PAPERCLIP_API_URL = apiUrl;
|
||||
return vars;
|
||||
}
|
||||
|
||||
export function defaultPathForPlatform() {
|
||||
if (process.platform === "win32") {
|
||||
return "C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem";
|
||||
}
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
|
||||
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
|
||||
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string) {
|
||||
try {
|
||||
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
const hasPathSeparator = command.includes("/") || command.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
return (await pathExists(absolute)) ? absolute : null;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
|
||||
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidates =
|
||||
process.platform === "win32"
|
||||
? hasExtension
|
||||
? [path.join(dir, command)]
|
||||
: exts.map((ext) => path.join(dir, `${command}${ext}`))
|
||||
: [path.join(dir, command)];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteForCmd(arg: string) {
|
||||
if (!arg.length) return '""';
|
||||
const escaped = arg.replace(/"/g, '""');
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
async function resolveSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<SpawnTarget> {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
const executable = resolved ?? command;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
if (/\.(cmd|bat)$/i.test(executable)) {
|
||||
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
};
|
||||
}
|
||||
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
||||
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
||||
return { ...env, PATH: defaultPathForPlatform() };
|
||||
}
|
||||
|
||||
export async function ensureAbsoluteDirectory(
|
||||
cwd: string,
|
||||
opts: { createIfMissing?: boolean } = {},
|
||||
) {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
|
||||
}
|
||||
|
||||
const assertDirectory = async () => {
|
||||
const stats = await fs.stat(cwd);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: "${cwd}"`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await assertDirectory();
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (!opts.createIfMissing || code !== "ENOENT") {
|
||||
if (code === "ENOENT") {
|
||||
throw new Error(`Working directory does not exist: "${cwd}"`);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
await assertDirectory();
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Could not create working directory "${cwd}": ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePaperclipSkillsDir(
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<string | null> {
|
||||
const candidates = [
|
||||
...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
|
||||
...additionalCandidates.map((candidate) => path.resolve(candidate)),
|
||||
];
|
||||
const seenRoots = new Set<string>();
|
||||
|
||||
for (const root of candidates) {
|
||||
if (seenRoots.has(root)) continue;
|
||||
seenRoots.add(root);
|
||||
const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
|
||||
if (isDirectory) return root;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function listPaperclipSkillEntries(
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<PaperclipSkillEntry[]> {
|
||||
const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates);
|
||||
if (!root) return [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
key: `paperclipai/paperclip/${entry.name}`,
|
||||
runtimeName: entry.name,
|
||||
source: path.join(root, entry.name),
|
||||
required: true,
|
||||
requiredReason: "Bundled Paperclip skills are always available for local adapters.",
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInstalledSkillTargets(skillsHome: string): Promise<Map<string, InstalledSkillTarget>> {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, InstalledSkillTarget>();
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(skillsHome, entry.name);
|
||||
const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null;
|
||||
out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildPersistentSkillSnapshot(
|
||||
options: PersistentSkillSnapshotOptions,
|
||||
): AdapterSkillSnapshot {
|
||||
const {
|
||||
adapterType,
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
installed,
|
||||
skillsHome,
|
||||
locationLabel,
|
||||
installedDetail,
|
||||
missingDetail,
|
||||
externalConflictDetail,
|
||||
externalDetail,
|
||||
} = options;
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings = [...(options.warnings ?? [])];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||
const desired = desiredSet.has(available.key);
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (installedEntry?.targetPath === available.source) {
|
||||
managed = true;
|
||||
state = desired ? "installed" : "stale";
|
||||
detail = installedDetail ?? null;
|
||||
} else if (installedEntry) {
|
||||
state = "external";
|
||||
detail = desired ? externalConflictDetail : externalDetail;
|
||||
} else if (desired) {
|
||||
state = "missing";
|
||||
detail = missingDetail;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed,
|
||||
state,
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.runtimeName),
|
||||
detail,
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: skillLocationLabel(locationLabel),
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: externalDetail,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
adapterType,
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfiguredPaperclipRuntimeSkills(value: unknown): PaperclipSkillEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: PaperclipSkillEntry[] = [];
|
||||
for (const rawEntry of value) {
|
||||
const entry = parseObject(rawEntry);
|
||||
const key = asString(entry.key, asString(entry.name, "")).trim();
|
||||
const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim();
|
||||
const source = asString(entry.source, "").trim();
|
||||
if (!key || !runtimeName || !source) continue;
|
||||
out.push({
|
||||
key,
|
||||
runtimeName,
|
||||
source,
|
||||
required: asBoolean(entry.required, false),
|
||||
requiredReason:
|
||||
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
|
||||
? entry.requiredReason.trim()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function readPaperclipRuntimeSkillEntries(
|
||||
config: Record<string, unknown>,
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<PaperclipSkillEntry[]> {
|
||||
const configuredEntries = normalizeConfiguredPaperclipRuntimeSkills(config.paperclipRuntimeSkills);
|
||||
if (configuredEntries.length > 0) return configuredEntries;
|
||||
return listPaperclipSkillEntries(moduleDir, additionalCandidates);
|
||||
}
|
||||
|
||||
export async function readPaperclipSkillMarkdown(
|
||||
moduleDir: string,
|
||||
skillKey: string,
|
||||
): Promise<string | null> {
|
||||
const normalized = skillKey.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
const entries = await listPaperclipSkillEntries(moduleDir);
|
||||
const match = entries.find((entry) => entry.key === normalized);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readPaperclipSkillSyncPreference(config: Record<string, unknown>): {
|
||||
explicit: boolean;
|
||||
desiredSkills: string[];
|
||||
} {
|
||||
const raw = config.paperclipSkillSync;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return { explicit: false, desiredSkills: [] };
|
||||
}
|
||||
const syncConfig = raw as Record<string, unknown>;
|
||||
const desiredValues = syncConfig.desiredSkills;
|
||||
const desired = Array.isArray(desiredValues)
|
||||
? desiredValues
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"),
|
||||
desiredSkills: Array.from(new Set(desired)),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizeDesiredPaperclipSkillReference(
|
||||
reference: string,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
|
||||
): string {
|
||||
const normalizedReference = reference.trim().toLowerCase();
|
||||
if (!normalizedReference) return "";
|
||||
|
||||
const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference);
|
||||
if (exactKey) return exactKey.key;
|
||||
|
||||
const byRuntimeName = availableEntries.filter((entry) =>
|
||||
typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference,
|
||||
);
|
||||
if (byRuntimeName.length === 1) return byRuntimeName[0]!.key;
|
||||
|
||||
const slugMatches = availableEntries.filter((entry) =>
|
||||
entry.key.trim().toLowerCase().split("/").pop() === normalizedReference,
|
||||
);
|
||||
if (slugMatches.length === 1) return slugMatches[0]!.key;
|
||||
|
||||
return normalizedReference;
|
||||
}
|
||||
|
||||
export function resolvePaperclipDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>,
|
||||
): string[] {
|
||||
const preference = readPaperclipSkillSyncPreference(config);
|
||||
const requiredSkills = availableEntries
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => entry.key);
|
||||
if (!preference.explicit) {
|
||||
return Array.from(new Set(requiredSkills));
|
||||
}
|
||||
const desiredSkills = preference.desiredSkills
|
||||
.map((reference) => canonicalizeDesiredPaperclipSkillReference(reference, availableEntries))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set([...requiredSkills, ...desiredSkills]));
|
||||
}
|
||||
|
||||
export function writePaperclipSkillSyncPreference(
|
||||
config: Record<string, unknown>,
|
||||
desiredSkills: string[],
|
||||
): Record<string, unknown> {
|
||||
const next = { ...config };
|
||||
const raw = next.paperclipSkillSync;
|
||||
const current =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? { ...(raw as Record<string, unknown>) }
|
||||
: {};
|
||||
current.desiredSkills = Array.from(
|
||||
new Set(
|
||||
desiredSkills
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
next.paperclipSkillSync = current;
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function ensurePaperclipSkillSymlink(
|
||||
source: string,
|
||||
target: string,
|
||||
linkSkill: (source: string, target: string) => Promise<void> = (linkSource, linkTarget) =>
|
||||
fs.symlink(linkSource, linkTarget),
|
||||
): Promise<"created" | "repaired" | "skipped"> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing) {
|
||||
await linkSkill(source, target);
|
||||
return "created";
|
||||
}
|
||||
|
||||
if (!existing.isSymbolicLink()) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) return "skipped";
|
||||
|
||||
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||
if (resolvedLinkedPath === source) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false);
|
||||
if (linkedPathExists) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
await fs.unlink(target);
|
||||
await linkSkill(source, target);
|
||||
return "repaired";
|
||||
}
|
||||
|
||||
export async function removeMaintainerOnlySkillSymlinks(
|
||||
skillsHome: string,
|
||||
allowedSkillNames: Iterable<string>,
|
||||
): Promise<string[]> {
|
||||
const allowed = new Set(Array.from(allowedSkillNames));
|
||||
try {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true });
|
||||
const removed: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (allowed.has(entry.name)) continue;
|
||||
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing?.isSymbolicLink()) continue;
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) continue;
|
||||
|
||||
const resolvedLinkedPath = path.isAbsolute(linkedPath)
|
||||
? linkedPath
|
||||
: path.resolve(path.dirname(target), linkedPath);
|
||||
if (
|
||||
!isMaintainerOnlySkillTarget(linkedPath) &&
|
||||
!isMaintainerOnlySkillTarget(resolvedLinkedPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.unlink(target);
|
||||
removed.push(entry.name);
|
||||
}
|
||||
|
||||
return removed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const resolved = await resolveCommandPath(command, cwd, env);
|
||||
if (resolved) return;
|
||||
if (command.includes("/") || command.includes("\\")) {
|
||||
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
throw new Error(`Command not found in PATH: "${command}"`);
|
||||
}
|
||||
|
||||
export async function runChildProcess(
|
||||
runId: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
|
||||
|
||||
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||
// don't refuse to start with "cannot be launched inside another session".
|
||||
// These vars leak in when the Paperclip server itself is started from
|
||||
// within a Claude Code session (e.g. `npx paperclipai run` in a terminal
|
||||
// owned by Claude Code) or when cron inherits a contaminated shell env.
|
||||
const CLAUDE_CODE_NESTING_VARS = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION",
|
||||
"CLAUDE_CODE_PARENT_SESSION",
|
||||
] as const;
|
||||
for (const key of CLAUDE_CODE_NESTING_VARS) {
|
||||
delete rawMerged[key];
|
||||
}
|
||||
|
||||
const mergedEnv = ensurePathInEnv(rawMerged);
|
||||
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||
.then((target) => {
|
||||
const child = spawn(target.command, target.args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
|
||||
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
||||
onLogError(err, runId, "failed to record child process metadata");
|
||||
});
|
||||
}
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
const text = String(chunk);
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
pid: child.pid ?? null,
|
||||
startedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
export interface SessionCompactionPolicy {
|
||||
enabled: boolean;
|
||||
maxSessionRuns: number;
|
||||
maxRawInputTokens: number;
|
||||
maxSessionAgeHours: number;
|
||||
}
|
||||
|
||||
export type NativeContextManagement = "confirmed" | "likely" | "unknown" | "none";
|
||||
|
||||
export interface AdapterSessionManagement {
|
||||
supportsSessionResume: boolean;
|
||||
nativeContextManagement: NativeContextManagement;
|
||||
defaultSessionCompaction: SessionCompactionPolicy;
|
||||
}
|
||||
|
||||
export interface ResolvedSessionCompactionPolicy {
|
||||
policy: SessionCompactionPolicy;
|
||||
adapterSessionManagement: AdapterSessionManagement | null;
|
||||
explicitOverride: Partial<SessionCompactionPolicy>;
|
||||
source: "adapter_default" | "agent_override" | "legacy_fallback";
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_COMPACTION_POLICY: SessionCompactionPolicy = {
|
||||
enabled: true,
|
||||
maxSessionRuns: 200,
|
||||
maxRawInputTokens: 2_000_000,
|
||||
maxSessionAgeHours: 72,
|
||||
};
|
||||
|
||||
// Adapters with native context management still participate in session resume,
|
||||
// but Paperclip should not rotate them using threshold-based compaction.
|
||||
const ADAPTER_MANAGED_SESSION_POLICY: SessionCompactionPolicy = {
|
||||
enabled: true,
|
||||
maxSessionRuns: 0,
|
||||
maxRawInputTokens: 0,
|
||||
maxSessionAgeHours: 0,
|
||||
};
|
||||
|
||||
export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"cursor",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
]);
|
||||
|
||||
export const ADAPTER_SESSION_MANAGEMENT: Record<string, AdapterSessionManagement> = {
|
||||
claude_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
codex_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "confirmed",
|
||||
defaultSessionCompaction: ADAPTER_MANAGED_SESSION_POLICY,
|
||||
},
|
||||
cursor: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
gemini_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
opencode_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
pi_local: {
|
||||
supportsSessionResume: true,
|
||||
nativeContextManagement: "unknown",
|
||||
defaultSessionCompaction: DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
},
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
if (typeof value !== "string") return undefined;
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : undefined;
|
||||
}
|
||||
|
||||
export function getAdapterSessionManagement(adapterType: string | null | undefined): AdapterSessionManagement | null {
|
||||
if (!adapterType) return null;
|
||||
return ADAPTER_SESSION_MANAGEMENT[adapterType] ?? null;
|
||||
}
|
||||
|
||||
export function readSessionCompactionOverride(runtimeConfig: unknown): Partial<SessionCompactionPolicy> {
|
||||
const runtime = isRecord(runtimeConfig) ? runtimeConfig : {};
|
||||
const heartbeat = isRecord(runtime.heartbeat) ? runtime.heartbeat : {};
|
||||
const compaction = isRecord(
|
||||
heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction,
|
||||
)
|
||||
? (heartbeat.sessionCompaction ?? heartbeat.sessionRotation ?? runtime.sessionCompaction) as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
const explicit: Partial<SessionCompactionPolicy> = {};
|
||||
const enabled = readBoolean(compaction.enabled);
|
||||
const maxSessionRuns = readNumber(compaction.maxSessionRuns);
|
||||
const maxRawInputTokens = readNumber(compaction.maxRawInputTokens);
|
||||
const maxSessionAgeHours = readNumber(compaction.maxSessionAgeHours);
|
||||
|
||||
if (enabled !== undefined) explicit.enabled = enabled;
|
||||
if (maxSessionRuns !== undefined) explicit.maxSessionRuns = maxSessionRuns;
|
||||
if (maxRawInputTokens !== undefined) explicit.maxRawInputTokens = maxRawInputTokens;
|
||||
if (maxSessionAgeHours !== undefined) explicit.maxSessionAgeHours = maxSessionAgeHours;
|
||||
|
||||
return explicit;
|
||||
}
|
||||
|
||||
export function resolveSessionCompactionPolicy(
|
||||
adapterType: string | null | undefined,
|
||||
runtimeConfig: unknown,
|
||||
): ResolvedSessionCompactionPolicy {
|
||||
const adapterSessionManagement = getAdapterSessionManagement(adapterType);
|
||||
const explicitOverride = readSessionCompactionOverride(runtimeConfig);
|
||||
const hasExplicitOverride = Object.keys(explicitOverride).length > 0;
|
||||
const fallbackEnabled = Boolean(adapterType && LEGACY_SESSIONED_ADAPTER_TYPES.has(adapterType));
|
||||
const basePolicy = adapterSessionManagement?.defaultSessionCompaction ?? {
|
||||
...DEFAULT_SESSION_COMPACTION_POLICY,
|
||||
enabled: fallbackEnabled,
|
||||
};
|
||||
|
||||
return {
|
||||
policy: {
|
||||
enabled: explicitOverride.enabled ?? basePolicy.enabled,
|
||||
maxSessionRuns: explicitOverride.maxSessionRuns ?? basePolicy.maxSessionRuns,
|
||||
maxRawInputTokens: explicitOverride.maxRawInputTokens ?? basePolicy.maxRawInputTokens,
|
||||
maxSessionAgeHours: explicitOverride.maxSessionAgeHours ?? basePolicy.maxSessionAgeHours,
|
||||
},
|
||||
adapterSessionManagement,
|
||||
explicitOverride,
|
||||
source: hasExplicitOverride
|
||||
? "agent_override"
|
||||
: adapterSessionManagement
|
||||
? "adapter_default"
|
||||
: "legacy_fallback",
|
||||
};
|
||||
}
|
||||
|
||||
export function hasSessionCompactionThresholds(policy: Pick<
|
||||
SessionCompactionPolicy,
|
||||
"maxSessionRuns" | "maxRawInputTokens" | "maxSessionAgeHours"
|
||||
>) {
|
||||
return policy.maxSessionRuns > 0 || policy.maxRawInputTokens > 0 || policy.maxSessionAgeHours > 0;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal adapter-facing interfaces (no drizzle dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AdapterAgent {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
adapterType: string | null;
|
||||
adapterConfig: unknown;
|
||||
}
|
||||
|
||||
export interface AdapterRuntime {
|
||||
/**
|
||||
* Legacy single session id view. Prefer `sessionParams` + `sessionDisplayId`.
|
||||
*/
|
||||
sessionId: string | null;
|
||||
sessionParams: Record<string, unknown> | null;
|
||||
sessionDisplayId: string | null;
|
||||
taskKey: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution types (moved from server/src/adapters/types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UsageSummary {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens?: number;
|
||||
}
|
||||
|
||||
export type AdapterBillingType =
|
||||
| "api"
|
||||
| "subscription"
|
||||
| "metered_api"
|
||||
| "subscription_included"
|
||||
| "subscription_overage"
|
||||
| "credits"
|
||||
| "fixed"
|
||||
| "unknown";
|
||||
|
||||
export interface AdapterRuntimeServiceReport {
|
||||
id?: string | null;
|
||||
projectId?: string | null;
|
||||
projectWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
||||
scopeId?: string | null;
|
||||
serviceName: string;
|
||||
status?: "starting" | "running" | "stopped" | "failed";
|
||||
lifecycle?: "shared" | "ephemeral";
|
||||
reuseKey?: string | null;
|
||||
command?: string | null;
|
||||
cwd?: string | null;
|
||||
port?: number | null;
|
||||
url?: string | null;
|
||||
providerRef?: string | null;
|
||||
ownerAgentId?: string | null;
|
||||
stopPolicy?: Record<string, unknown> | null;
|
||||
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
||||
}
|
||||
|
||||
export interface AdapterExecutionResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
errorMessage?: string | null;
|
||||
errorCode?: string | null;
|
||||
errorMeta?: Record<string, unknown>;
|
||||
usage?: UsageSummary;
|
||||
/**
|
||||
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.
|
||||
*/
|
||||
sessionId?: string | null;
|
||||
sessionParams?: Record<string, unknown> | null;
|
||||
sessionDisplayId?: string | null;
|
||||
provider?: string | null;
|
||||
biller?: string | null;
|
||||
model?: string | null;
|
||||
billingType?: AdapterBillingType | null;
|
||||
costUsd?: number | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
||||
summary?: string | null;
|
||||
clearSession?: boolean;
|
||||
question?: {
|
||||
prompt: string;
|
||||
choices: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdapterSessionCodec {
|
||||
deserialize(raw: unknown): Record<string, unknown> | null;
|
||||
serialize(params: Record<string, unknown> | null): Record<string, unknown> | null;
|
||||
getDisplayId?: (params: Record<string, unknown> | null) => string | null;
|
||||
}
|
||||
|
||||
export interface AdapterInvocationMeta {
|
||||
adapterType: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
commandArgs?: string[];
|
||||
commandNotes?: string[];
|
||||
env?: Record<string, string>;
|
||||
prompt?: string;
|
||||
promptMetrics?: Record<string, number>;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdapterExecutionContext {
|
||||
runId: string;
|
||||
agent: AdapterAgent;
|
||||
runtime: AdapterRuntime;
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
||||
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface AdapterModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
|
||||
|
||||
export interface AdapterEnvironmentCheck {
|
||||
code: string;
|
||||
level: AdapterEnvironmentCheckLevel;
|
||||
message: string;
|
||||
detail?: string | null;
|
||||
hint?: string | null;
|
||||
}
|
||||
|
||||
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
|
||||
|
||||
export interface AdapterEnvironmentTestResult {
|
||||
adapterType: string;
|
||||
status: AdapterEnvironmentTestStatus;
|
||||
checks: AdapterEnvironmentCheck[];
|
||||
testedAt: string;
|
||||
}
|
||||
|
||||
export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral";
|
||||
|
||||
export type AdapterSkillState =
|
||||
| "available"
|
||||
| "configured"
|
||||
| "installed"
|
||||
| "missing"
|
||||
| "stale"
|
||||
| "external";
|
||||
|
||||
export type AdapterSkillOrigin =
|
||||
| "company_managed"
|
||||
| "paperclip_required"
|
||||
| "user_installed"
|
||||
| "external_unknown";
|
||||
|
||||
export interface AdapterSkillEntry {
|
||||
key: string;
|
||||
runtimeName: string | null;
|
||||
desired: boolean;
|
||||
managed: boolean;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
state: AdapterSkillState;
|
||||
origin?: AdapterSkillOrigin;
|
||||
originLabel?: string | null;
|
||||
locationLabel?: string | null;
|
||||
readOnly?: boolean;
|
||||
sourcePath?: string | null;
|
||||
targetPath?: string | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterSkillSnapshot {
|
||||
adapterType: string;
|
||||
supported: boolean;
|
||||
mode: AdapterSkillSyncMode;
|
||||
desiredSkills: string[];
|
||||
entries: AdapterSkillEntry[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface AdapterSkillContext {
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdapterEnvironmentTestContext {
|
||||
companyId: string;
|
||||
adapterType: string;
|
||||
config: Record<string, unknown>;
|
||||
deployment?: {
|
||||
mode?: "local_trusted" | "authenticated";
|
||||
exposure?: "private" | "public";
|
||||
bindHost?: string | null;
|
||||
allowedHostnames?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
|
||||
export interface HireApprovedPayload {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
adapterType: string;
|
||||
/** "join_request" | "approval" */
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt: string;
|
||||
/** Canonical operator-facing message for cloud adapters to show the user. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
|
||||
export interface HireApprovedHookResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quota window types — used by adapters that can report provider quota/rate-limit state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** a single rate-limit or usage window returned by a provider quota API */
|
||||
export interface QuotaWindow {
|
||||
/** human label, e.g. "5h", "7d", "Sonnet 7d", "Credits" */
|
||||
label: string;
|
||||
/** percent of the window already consumed (0-100), null when not reported */
|
||||
usedPercent: number | null;
|
||||
/** iso timestamp when this window resets, null when not reported */
|
||||
resetsAt: string | null;
|
||||
/** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
|
||||
valueLabel: string | null;
|
||||
/** optional supporting text, e.g. reset details or provider-specific notes */
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
/** result for one provider from getQuotaWindows() */
|
||||
export interface ProviderQuotaResult {
|
||||
/** provider slug, e.g. "anthropic", "openai" */
|
||||
provider: string;
|
||||
/** source label when the provider reports where the quota data came from */
|
||||
source?: string | null;
|
||||
/** true when the fetch succeeded and windows is populated */
|
||||
ok: boolean;
|
||||
/** error message when ok is false */
|
||||
error?: string;
|
||||
windows: QuotaWindow[];
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
||||
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
|
||||
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
|
||||
sessionCodec?: AdapterSessionCodec;
|
||||
sessionManagement?: import("./session-compaction.js").AdapterSessionManagement;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
models?: AdapterModel[];
|
||||
listModels?: () => Promise<AdapterModel[]>;
|
||||
agentConfigurationDoc?: string;
|
||||
/**
|
||||
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
|
||||
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
|
||||
*/
|
||||
onHireApproved?: (
|
||||
payload: HireApprovedPayload,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) => Promise<HireApprovedHookResult>;
|
||||
/**
|
||||
* Optional: fetch live provider quota/rate-limit windows for this adapter.
|
||||
* Returns a ProviderQuotaResult so the server can aggregate across adapters
|
||||
* without knowing provider-specific credential paths or API shapes.
|
||||
*/
|
||||
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI types (moved from ui/src/adapters/types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
||||
| { kind: "user"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
||||
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
| { kind: "stderr"; ts: string; text: string }
|
||||
| { kind: "system"; ts: string; text: string }
|
||||
| { kind: "stdout"; ts: string; text: string };
|
||||
|
||||
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI types (moved from cli/src/adapters/types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CLIAdapterModule {
|
||||
type: string;
|
||||
formatStdoutEvent: (line: string, debug: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI config form values (moved from ui/src/components/AgentConfigForm.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateConfigValues {
|
||||
adapterType: string;
|
||||
cwd: string;
|
||||
instructionsFilePath?: string;
|
||||
promptTemplate: string;
|
||||
model: string;
|
||||
thinkingEffort: string;
|
||||
chrome: boolean;
|
||||
dangerouslySkipPermissions: boolean;
|
||||
search: boolean;
|
||||
dangerouslyBypassSandbox: boolean;
|
||||
command: string;
|
||||
args: string;
|
||||
extraArgs: string;
|
||||
envVars: string;
|
||||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
workspaceBranchTemplate?: string;
|
||||
worktreeParentDir?: string;
|
||||
runtimeServicesJson?: string;
|
||||
maxTurnsPerRun: number;
|
||||
heartbeatEnabled: boolean;
|
||||
intervalSec: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/claude-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json",
|
||||
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||
const obj = value as Record<string, unknown>;
|
||||
const message =
|
||||
(typeof obj.message === "string" && obj.message) ||
|
||||
(typeof obj.error === "string" && obj.error) ||
|
||||
(typeof obj.code === "string" && obj.code) ||
|
||||
"";
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(obj);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
||||
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message =
|
||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||
? (parsed.message as Record<string, unknown>)
|
||||
: {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const blockRaw of content) {
|
||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||
const block = blockRaw as Record<string, unknown>;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
} else if (blockType === "tool_use") {
|
||||
const name = typeof block.name === "string" ? block.name : "unknown";
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (block.input !== undefined) {
|
||||
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage =
|
||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||
? (parsed.usage as Record<string, unknown>)
|
||||
: {};
|
||||
const input = Number(usage.input_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? 0);
|
||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
||||
if (resultText) {
|
||||
console.log(pc.green("result:"));
|
||||
console.log(resultText);
|
||||
}
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
||||
console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
|
||||
if (errors.length > 0) {
|
||||
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
pc.blue(
|
||||
`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printClaudeStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
captureClaudeCliUsageText,
|
||||
fetchClaudeCliQuota,
|
||||
fetchClaudeQuota,
|
||||
getQuotaWindows,
|
||||
parseClaudeCliUsageText,
|
||||
readClaudeAuthStatus,
|
||||
readClaudeToken,
|
||||
} from "../server/quota.js";
|
||||
|
||||
interface ProbeArgs {
|
||||
json: boolean;
|
||||
includeRawCli: boolean;
|
||||
oauthOnly: boolean;
|
||||
cliOnly: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ProbeArgs {
|
||||
return {
|
||||
json: argv.includes("--json"),
|
||||
includeRawCli: argv.includes("--raw-cli"),
|
||||
oauthOnly: argv.includes("--oauth-only"),
|
||||
cliOnly: argv.includes("--cli-only"),
|
||||
};
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.oauthOnly && args.cliOnly) {
|
||||
throw new Error("Choose either --oauth-only or --cli-only, not both.");
|
||||
}
|
||||
|
||||
const authStatus = await readClaudeAuthStatus();
|
||||
const token = await readClaudeToken();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
authStatus,
|
||||
tokenAvailable: token != null,
|
||||
};
|
||||
|
||||
if (!args.cliOnly) {
|
||||
if (!token) {
|
||||
result.oauth = {
|
||||
ok: false,
|
||||
error: "No Claude OAuth access token found in local credentials files.",
|
||||
windows: [],
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
result.oauth = {
|
||||
ok: true,
|
||||
windows: await fetchClaudeQuota(token),
|
||||
};
|
||||
} catch (error) {
|
||||
result.oauth = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.oauthOnly) {
|
||||
try {
|
||||
const rawCliText = args.includeRawCli ? await captureClaudeCliUsageText() : null;
|
||||
const windows = rawCliText ? parseClaudeCliUsageText(rawCliText) : await fetchClaudeCliQuota();
|
||||
result.cli = rawCliText
|
||||
? {
|
||||
ok: true,
|
||||
windows,
|
||||
rawText: rawCliText,
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
windows,
|
||||
};
|
||||
} catch (error) {
|
||||
result.cli = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.oauthOnly && !args.cliOnly) {
|
||||
try {
|
||||
result.aggregated = await getQuotaWindows();
|
||||
} catch (error) {
|
||||
result.aggregated = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const oauthOk = (result.oauth as { ok?: boolean } | undefined)?.ok === true;
|
||||
const cliOk = (result.cli as { ok?: boolean } | undefined)?.ok === true;
|
||||
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
|
||||
const ok = oauthOk || cliOk || aggregatedOk;
|
||||
|
||||
if (args.json || process.stdout.isTTY === false) {
|
||||
console.log(JSON.stringify({ ok, ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`timestamp: ${result.timestamp}`);
|
||||
console.log(`auth: ${JSON.stringify(authStatus)}`);
|
||||
console.log(`tokenAvailable: ${token != null}`);
|
||||
if (result.oauth) console.log(`oauth: ${JSON.stringify(result.oauth, null, 2)}`);
|
||||
if (result.cli) console.log(`cli: ${JSON.stringify(result.cli, null, 2)}`);
|
||||
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,37 @@
|
||||
export const type = "claude_local";
|
||||
export const label = "Claude Code (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# claude_local agent configuration
|
||||
|
||||
Adapter: claude_local
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
|
||||
- model (string, optional): Claude model id
|
||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
||||
- chrome (boolean, optional): pass --chrome when running Claude
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- maxTurnsPerRun (number, optional): max turns for one run
|
||||
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
||||
- command (string, optional): defaults to "claude"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
@@ -0,0 +1,589 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
parseJson,
|
||||
buildPaperclipEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
parseClaudeStreamJson,
|
||||
describeClaudeFailure,
|
||||
detectClaudeLoginRequired,
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
import { resolveClaudeDesiredSkillNames } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
async function buildSkillsDir(config: Record<string, unknown>): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredNames = new Set(
|
||||
resolveClaudeDesiredSkillNames(
|
||||
config,
|
||||
availableEntries,
|
||||
),
|
||||
);
|
||||
for (const entry of availableEntries) {
|
||||
if (!desiredNames.has(entry.key)) continue;
|
||||
await fs.symlink(
|
||||
entry.source,
|
||||
path.join(target, entry.runtimeName),
|
||||
);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
interface ClaudeExecutionInput {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
function buildLoginResult(input: {
|
||||
proc: RunProcessResult;
|
||||
loginUrl: string | null;
|
||||
}) {
|
||||
return {
|
||||
exitCode: input.proc.exitCode,
|
||||
signal: input.proc.signal,
|
||||
timedOut: input.proc.timedOut,
|
||||
stdout: input.proc.stdout,
|
||||
stderr: input.proc.stderr,
|
||||
loginUrl: input.loginUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||
const { runId, agent, config, context, authToken } = input;
|
||||
|
||||
const command = asString(config.command, "claude");
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const agentHome = asString(workspaceContext.agentHome, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
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)
|
||||
: [];
|
||||
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
return {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runClaudeLogin(input: {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context?: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
}) {
|
||||
const onLog = input.onLog ?? (async () => {});
|
||||
const runtime = await buildClaudeRuntimeConfig({
|
||||
runId: input.runId,
|
||||
agent: input.agent,
|
||||
config: input.config,
|
||||
context: input.context ?? {},
|
||||
authToken: input.authToken,
|
||||
});
|
||||
|
||||
const proc = await runChildProcess(input.runId, runtime.command, ["login"], {
|
||||
cwd: runtime.cwd,
|
||||
env: runtime.env,
|
||||
timeoutSec: runtime.timeoutSec,
|
||||
graceSec: runtime.graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed: null,
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
});
|
||||
|
||||
return buildLoginResult({
|
||||
proc,
|
||||
loginUrl: loginMeta.loginUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const model = asString(config.model, "");
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
const commandNotes = instructionsFilePath
|
||||
? [
|
||||
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
||||
]
|
||||
: [];
|
||||
|
||||
const runtimeConfig = await buildClaudeRuntimeConfig({
|
||||
runId,
|
||||
agent,
|
||||
config,
|
||||
context,
|
||||
authToken,
|
||||
});
|
||||
const {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
} = runtimeConfig;
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveClaudeBillingType(effectiveEnv);
|
||||
const skillsDir = await buildSkillsDir(config);
|
||||
|
||||
// When instructionsFilePath is configured, create a combined temp file that
|
||||
// includes both the file content and the path directive, so we only need
|
||||
// --append-system-prompt-file (Claude CLI forbids using both flags together).
|
||||
let effectiveInstructionsFilePath: string | undefined = instructionsFilePath;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
||||
const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`;
|
||||
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
||||
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
||||
effectiveInstructionsFilePath = combinedPath;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
effectiveInstructionsFilePath = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
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 renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
if (chrome) args.push("--chrome");
|
||||
if (model) args.push("--model", model);
|
||||
if (effort) args.push("--effort", effort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (effectiveInstructionsFilePath) {
|
||||
args.push("--append-system-prompt-file", effectiveInstructionsFilePath);
|
||||
}
|
||||
args.push("--add-dir", skillsDir);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
|
||||
const stderrLine =
|
||||
proc.stderr
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? "";
|
||||
|
||||
if ((proc.exitCode ?? 0) === 0) {
|
||||
return "Failed to parse claude JSON output";
|
||||
}
|
||||
|
||||
return stderrLine
|
||||
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
|
||||
: `Claude exited with code ${proc.exitCode ?? -1}`;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildClaudeArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "claude_local",
|
||||
command,
|
||||
cwd,
|
||||
commandArgs: args,
|
||||
commandNotes,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
|
||||
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
||||
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
||||
return { proc, parsedStream, parsed };
|
||||
};
|
||||
|
||||
const toAdapterResult = (
|
||||
attempt: {
|
||||
proc: RunProcessResult;
|
||||
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
|
||||
parsed: Record<string, unknown> | null;
|
||||
},
|
||||
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
|
||||
): AdapterExecutionResult => {
|
||||
const { proc, parsedStream, parsed } = attempt;
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed,
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
});
|
||||
const errorMeta =
|
||||
loginMeta.loginUrl != null
|
||||
? {
|
||||
loginUrl: loginMeta.loginUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (proc.timedOut) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
errorCode: "timeout",
|
||||
errorMeta,
|
||||
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
||||
};
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: parseFallbackErrorMessage(proc),
|
||||
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
||||
errorMeta,
|
||||
resultJson: {
|
||||
stdout: proc.stdout,
|
||||
stderr: proc.stderr,
|
||||
},
|
||||
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
||||
};
|
||||
}
|
||||
|
||||
const usage =
|
||||
parsedStream.usage ??
|
||||
(() => {
|
||||
const usageObj = parseObject(parsed.usage);
|
||||
return {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
})();
|
||||
|
||||
const resolvedSessionId =
|
||||
parsedStream.sessionId ??
|
||||
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
|
||||
|
||||
return {
|
||||
exitCode: proc.exitCode,
|
||||
signal: proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
||||
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
||||
errorMeta,
|
||||
usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "anthropic",
|
||||
biller: "anthropic",
|
||||
model: parsedStream.model || asString(parsed.model, model),
|
||||
billingType,
|
||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||
resultJson: parsed,
|
||||
summary: parsedStream.summary || asString(parsed.result, ""),
|
||||
clearSession: clearSessionForMaxTurns || Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const initial = await runAttempt(sessionId ?? null);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
initial.parsed &&
|
||||
isClaudeUnknownSessionError(initial.parsed)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
} finally {
|
||||
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export { execute, runClaudeLogin } from "./execute.js";
|
||||
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseClaudeStreamJson,
|
||||
describeClaudeFailure,
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readClaudeAuthStatus,
|
||||
readClaudeToken,
|
||||
fetchClaudeQuota,
|
||||
fetchClaudeCliQuota,
|
||||
captureClaudeCliUsageText,
|
||||
parseClaudeCliUsageText,
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
claudeConfigDir,
|
||||
} from "./quota.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { UsageSummary } from "@paperclipai/adapter-utils";
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
|
||||
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
|
||||
|
||||
export function parseClaudeStreamJson(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
let model = "";
|
||||
let finalResult: Record<string, unknown> | null = null;
|
||||
const assistantTexts: string[] = [];
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
if (type === "system" && asString(event.subtype, "") === "init") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
model = asString(event.model, model);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
const message = parseObject(event.message);
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const entry of content) {
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const block = entry as Record<string, unknown>;
|
||||
if (asString(block.type, "") === "text") {
|
||||
const text = asString(block.text, "");
|
||||
if (text) assistantTexts.push(text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
finalResult = event;
|
||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
return {
|
||||
sessionId,
|
||||
model,
|
||||
costUsd: null as number | null,
|
||||
usage: null as UsageSummary | null,
|
||||
summary: assistantTexts.join("\n\n").trim(),
|
||||
resultJson: null as Record<string, unknown> | null,
|
||||
};
|
||||
}
|
||||
|
||||
const usageObj = parseObject(finalResult.usage);
|
||||
const usage: UsageSummary = {
|
||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||
};
|
||||
const costRaw = finalResult.total_cost_usd;
|
||||
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
||||
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
model,
|
||||
costUsd,
|
||||
usage,
|
||||
summary,
|
||||
resultJson: finalResult,
|
||||
};
|
||||
}
|
||||
|
||||
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||
const messages: string[] = [];
|
||||
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string") {
|
||||
const msg = entry.trim();
|
||||
if (msg) messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||
if (msg) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
messages.push(JSON.stringify(obj));
|
||||
} catch {
|
||||
// skip non-serializable entry
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function extractClaudeLoginUrl(text: string): string | null {
|
||||
const match = text.match(URL_RE);
|
||||
if (!match || match.length === 0) return null;
|
||||
for (const rawUrl of match) {
|
||||
const cleaned = rawUrl.replace(/[\])}.!,?;:'\"]+$/g, "");
|
||||
if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) {
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
return match[0]?.replace(/[\])}.!,?;:'\"]+$/g, "") ?? null;
|
||||
}
|
||||
|
||||
export function detectClaudeLoginRequired(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { requiresLogin: boolean; loginUrl: string | null } {
|
||||
const resultText = asString(input.parsed?.result, "").trim();
|
||||
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
|
||||
return {
|
||||
requiresLogin,
|
||||
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
|
||||
const subtype = asString(parsed.subtype, "");
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
const errors = extractClaudeErrorMessages(parsed);
|
||||
|
||||
let detail = resultText;
|
||||
if (!detail && errors.length > 0) {
|
||||
detail = errors[0] ?? "";
|
||||
}
|
||||
|
||||
const parts = ["Claude run failed"];
|
||||
if (subtype) parts.push(`subtype=${subtype}`);
|
||||
if (detail) parts.push(detail);
|
||||
return parts.length > 1 ? parts.join(": ") : null;
|
||||
}
|
||||
|
||||
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!parsed) return false;
|
||||
|
||||
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error_max_turns") return true;
|
||||
|
||||
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
|
||||
if (stopReason === "max_turns") return true;
|
||||
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
return /max(?:imum)?\s+turns?/i.test(resultText);
|
||||
}
|
||||
|
||||
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
||||
const resultText = asString(parsed.result, "").trim();
|
||||
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
||||
.map((msg) => msg.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return allMessages.some((msg) =>
|
||||
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CLAUDE_USAGE_SOURCE_OAUTH = "anthropic-oauth";
|
||||
const CLAUDE_USAGE_SOURCE_CLI = "claude-cli";
|
||||
|
||||
export function claudeConfigDir(): string {
|
||||
const fromEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
function hasNonEmptyProcessEnv(key: string): boolean {
|
||||
const value = process.env[key];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function createClaudeQuotaEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (typeof value !== "string") continue;
|
||||
if (key.startsWith("ANTHROPIC_")) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function stripBackspaces(text: string): string {
|
||||
let out = "";
|
||||
for (const char of text) {
|
||||
if (char === "\b") {
|
||||
out = out.slice(0, -1);
|
||||
} else {
|
||||
out += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text
|
||||
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
|
||||
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
||||
}
|
||||
|
||||
function cleanTerminalText(text: string): string {
|
||||
return stripAnsi(stripBackspaces(text))
|
||||
.replace(/\u0000/g, "")
|
||||
.replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
function normalizeForLabelSearch(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function trimToLatestUsagePanel(text: string): string | null {
|
||||
const lower = text.toLowerCase();
|
||||
const settingsIndex = lower.lastIndexOf("settings:");
|
||||
if (settingsIndex < 0) return null;
|
||||
let tail = text.slice(settingsIndex);
|
||||
const tailLower = tail.toLowerCase();
|
||||
if (!tailLower.includes("usage")) return null;
|
||||
if (!tailLower.includes("current session") && !tailLower.includes("loading usage")) return null;
|
||||
const stopMarkers = [
|
||||
"status dialog dismissed",
|
||||
"checking for updates",
|
||||
"press ctrl-c again to exit",
|
||||
];
|
||||
let stopIndex = -1;
|
||||
for (const marker of stopMarkers) {
|
||||
const markerIndex = tailLower.indexOf(marker);
|
||||
if (markerIndex >= 0 && (stopIndex === -1 || markerIndex < stopIndex)) {
|
||||
stopIndex = markerIndex;
|
||||
}
|
||||
}
|
||||
if (stopIndex >= 0) {
|
||||
tail = tail.slice(0, stopIndex);
|
||||
}
|
||||
return tail;
|
||||
}
|
||||
|
||||
async function readClaudeTokenFromFile(credPath: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(credPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const oauth = obj["claudeAiOauth"];
|
||||
if (typeof oauth !== "object" || oauth === null) return null;
|
||||
const token = (oauth as Record<string, unknown>)["accessToken"];
|
||||
return typeof token === "string" && token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
interface ClaudeAuthStatus {
|
||||
loggedIn: boolean;
|
||||
authMethod: string | null;
|
||||
subscriptionType: string | null;
|
||||
}
|
||||
|
||||
export async function readClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("claude", ["auth", "status"], {
|
||||
env: process.env,
|
||||
timeout: 5_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as Record<string, unknown>;
|
||||
return {
|
||||
loggedIn: parsed.loggedIn === true,
|
||||
authMethod: typeof parsed.authMethod === "string" ? parsed.authMethod : null,
|
||||
subscriptionType: typeof parsed.subscriptionType === "string" ? parsed.subscriptionType : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string | null {
|
||||
if (!status?.loggedIn || status.authMethod !== "claude.ai") return null;
|
||||
return status.subscriptionType
|
||||
? `Claude is logged in via claude.ai (${status.subscriptionType})`
|
||||
: "Claude is logged in via claude.ai";
|
||||
}
|
||||
|
||||
export async function readClaudeToken(): Promise<string | null> {
|
||||
const configDir = claudeConfigDir();
|
||||
for (const filename of [".credentials.json", "credentials.json"]) {
|
||||
const token = await readClaudeTokenFromFile(path.join(configDir, filename));
|
||||
if (token) return token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AnthropicUsageWindow {
|
||||
utilization?: number | null;
|
||||
resets_at?: string | null;
|
||||
}
|
||||
|
||||
interface AnthropicExtraUsage {
|
||||
is_enabled?: boolean | null;
|
||||
monthly_limit?: number | null;
|
||||
used_credits?: number | null;
|
||||
utilization?: number | null;
|
||||
currency?: string | null;
|
||||
}
|
||||
|
||||
interface AnthropicUsageResponse {
|
||||
five_hour?: AnthropicUsageWindow | null;
|
||||
seven_day?: AnthropicUsageWindow | null;
|
||||
seven_day_sonnet?: AnthropicUsageWindow | null;
|
||||
seven_day_opus?: AnthropicUsageWindow | null;
|
||||
extra_usage?: AnthropicExtraUsage | null;
|
||||
}
|
||||
|
||||
function formatCurrencyAmount(value: number, currency: string | null | undefined): string {
|
||||
const code = typeof currency === "string" && currency.trim().length > 0 ? currency.trim().toUpperCase() : "USD";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null {
|
||||
const monthlyLimit = extraUsage.monthly_limit;
|
||||
const usedCredits = extraUsage.used_credits;
|
||||
if (
|
||||
typeof monthlyLimit !== "number" ||
|
||||
!Number.isFinite(monthlyLimit) ||
|
||||
typeof usedCredits !== "number" ||
|
||||
!Number.isFinite(usedCredits)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`;
|
||||
}
|
||||
|
||||
/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */
|
||||
export function toPercent(utilization: number | null | undefined): number | null {
|
||||
if (utilization == null) return null;
|
||||
return Math.min(100, Math.round(utilization * 100));
|
||||
}
|
||||
|
||||
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
|
||||
export async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
if (!resp.ok) throw new Error(`anthropic usage api returned ${resp.status}`);
|
||||
const body = (await resp.json()) as AnthropicUsageResponse;
|
||||
const windows: QuotaWindow[] = [];
|
||||
|
||||
if (body.five_hour != null) {
|
||||
windows.push({
|
||||
label: "Current session",
|
||||
usedPercent: toPercent(body.five_hour.utilization),
|
||||
resetsAt: body.five_hour.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day != null) {
|
||||
windows.push({
|
||||
label: "Current week (all models)",
|
||||
usedPercent: toPercent(body.seven_day.utilization),
|
||||
resetsAt: body.seven_day.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day_sonnet != null) {
|
||||
windows.push({
|
||||
label: "Current week (Sonnet only)",
|
||||
usedPercent: toPercent(body.seven_day_sonnet.utilization),
|
||||
resetsAt: body.seven_day_sonnet.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.seven_day_opus != null) {
|
||||
windows.push({
|
||||
label: "Current week (Opus only)",
|
||||
usedPercent: toPercent(body.seven_day_opus.utilization),
|
||||
resetsAt: body.seven_day_opus.resets_at ?? null,
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.extra_usage != null) {
|
||||
windows.push({
|
||||
label: "Extra usage",
|
||||
usedPercent: body.extra_usage.is_enabled === false ? null : toPercent(body.extra_usage.utilization),
|
||||
resetsAt: null,
|
||||
valueLabel:
|
||||
body.extra_usage.is_enabled === false
|
||||
? "Not enabled"
|
||||
: formatExtraUsageLabel(body.extra_usage),
|
||||
detail:
|
||||
body.extra_usage.is_enabled === false
|
||||
? "Extra usage not enabled"
|
||||
: "Monthly extra usage pool",
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function usageOutputLooksRelevant(text: string): boolean {
|
||||
const normalized = normalizeForLabelSearch(text);
|
||||
return normalized.includes("currentsession")
|
||||
|| normalized.includes("currentweek")
|
||||
|| normalized.includes("loadingusage")
|
||||
|| normalized.includes("failedtoloadusagedata")
|
||||
|| normalized.includes("tokenexpired")
|
||||
|| normalized.includes("authenticationerror")
|
||||
|| normalized.includes("ratelimited");
|
||||
}
|
||||
|
||||
function usageOutputLooksComplete(text: string): boolean {
|
||||
const normalized = normalizeForLabelSearch(text);
|
||||
if (
|
||||
normalized.includes("failedtoloadusagedata")
|
||||
|| normalized.includes("tokenexpired")
|
||||
|| normalized.includes("authenticationerror")
|
||||
|| normalized.includes("ratelimited")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return normalized.includes("currentsession")
|
||||
&& (normalized.includes("currentweek") || normalized.includes("extrausage"))
|
||||
&& /[0-9]{1,3}(?:\.[0-9]+)?%/i.test(text);
|
||||
}
|
||||
|
||||
function extractUsageError(text: string): string | null {
|
||||
const lower = text.toLowerCase();
|
||||
const compact = lower.replace(/\s+/g, "");
|
||||
if (lower.includes("token_expired") || lower.includes("token has expired")) {
|
||||
return "Claude CLI token expired. Run `claude login` to refresh.";
|
||||
}
|
||||
if (lower.includes("authentication_error")) {
|
||||
return "Claude CLI authentication error. Run `claude login`.";
|
||||
}
|
||||
if (lower.includes("rate_limit_error") || lower.includes("rate limited") || compact.includes("ratelimited")) {
|
||||
return "Claude CLI usage endpoint is rate limited right now. Please try again later.";
|
||||
}
|
||||
if (lower.includes("failed to load usage data") || compact.includes("failedtoloadusagedata")) {
|
||||
return "Claude CLI could not load usage data. Open the CLI and retry `/usage`.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function percentFromLine(line: string): number | null {
|
||||
const match = line.match(/([0-9]{1,3}(?:\.[0-9]+)?)\s*%/i);
|
||||
if (!match) return null;
|
||||
const rawValue = Number(match[1]);
|
||||
if (!Number.isFinite(rawValue)) return null;
|
||||
const clamped = Math.min(100, Math.max(0, rawValue));
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes("remaining") || lower.includes("left") || lower.includes("available")) {
|
||||
return Math.max(0, Math.min(100, Math.round(100 - clamped)));
|
||||
}
|
||||
return Math.round(clamped);
|
||||
}
|
||||
|
||||
function isQuotaLabel(line: string): boolean {
|
||||
const normalized = normalizeForLabelSearch(line);
|
||||
return normalized === "currentsession"
|
||||
|| normalized === "currentweekallmodels"
|
||||
|| normalized === "currentweeksonnetonly"
|
||||
|| normalized === "currentweeksonnet"
|
||||
|| normalized === "currentweekopusonly"
|
||||
|| normalized === "currentweekopus"
|
||||
|| normalized === "extrausage";
|
||||
}
|
||||
|
||||
function canonicalQuotaLabel(line: string): string {
|
||||
switch (normalizeForLabelSearch(line)) {
|
||||
case "currentsession":
|
||||
return "Current session";
|
||||
case "currentweekallmodels":
|
||||
return "Current week (all models)";
|
||||
case "currentweeksonnetonly":
|
||||
case "currentweeksonnet":
|
||||
return "Current week (Sonnet only)";
|
||||
case "currentweekopusonly":
|
||||
case "currentweekopus":
|
||||
return "Current week (Opus only)";
|
||||
case "extrausage":
|
||||
return "Extra usage";
|
||||
default:
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
function formatClaudeCliDetail(label: string, lines: string[]): string | null {
|
||||
const normalizedLabel = normalizeForLabelSearch(label);
|
||||
if (normalizedLabel === "extrausage") {
|
||||
const compact = lines.join(" ").replace(/\s+/g, "").toLowerCase();
|
||||
if (compact.includes("extrausagenotenabled")) {
|
||||
return "Extra usage not enabled • /extra-usage to enable";
|
||||
}
|
||||
const firstLine = lines.find((line) => line.trim().length > 0) ?? null;
|
||||
return firstLine;
|
||||
}
|
||||
|
||||
const resetLine = lines.find((line) => /^resets/i.test(line) || normalizeForLabelSearch(line).startsWith("resets"));
|
||||
if (!resetLine) return null;
|
||||
return resetLine
|
||||
.replace(/^Resets/i, "Resets ")
|
||||
.replace(/([A-Z][a-z]{2})(\d)/g, "$1 $2")
|
||||
.replace(/(\d)at(\d)/g, "$1 at $2")
|
||||
.replace(/(am|pm)\(/gi, "$1 (")
|
||||
.replace(/([A-Za-z])\(/g, "$1 (")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseClaudeCliUsageText(text: string): QuotaWindow[] {
|
||||
const cleaned = trimToLatestUsagePanel(cleanTerminalText(text)) ?? cleanTerminalText(text);
|
||||
const usageError = extractUsageError(cleaned);
|
||||
if (usageError) throw new Error(usageError);
|
||||
|
||||
const lines = cleaned
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const sections: Array<{ label: string; lines: string[] }> = [];
|
||||
let current: { label: string; lines: string[] } | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (isQuotaLabel(line)) {
|
||||
if (current) sections.push(current);
|
||||
current = { label: canonicalQuotaLabel(line), lines: [] };
|
||||
continue;
|
||||
}
|
||||
if (current) current.lines.push(line);
|
||||
}
|
||||
if (current) sections.push(current);
|
||||
|
||||
const windows = sections.map<QuotaWindow>((section) => {
|
||||
const usedPercent = section.lines.map(percentFromLine).find((value) => value != null) ?? null;
|
||||
return {
|
||||
label: section.label,
|
||||
usedPercent,
|
||||
resetsAt: null,
|
||||
valueLabel: null,
|
||||
detail: formatClaudeCliDetail(section.label, section.lines),
|
||||
};
|
||||
});
|
||||
|
||||
if (!windows.some((window) => normalizeForLabelSearch(window.label) === "currentsession")) {
|
||||
throw new Error("Could not parse Claude CLI usage output.");
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function quoteForShell(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function buildClaudeCliShellProbeCommand(): string {
|
||||
const feed = "(sleep 2; printf '/usage\\r'; sleep 6; printf '\\033'; sleep 1; printf '\\003')";
|
||||
const claudeCommand = "claude --tools \"\"";
|
||||
if (process.platform === "darwin") {
|
||||
return `${feed} | script -q /dev/null ${claudeCommand}`;
|
||||
}
|
||||
return `${feed} | script -q -e -f -c ${quoteForShell(claudeCommand)} /dev/null`;
|
||||
}
|
||||
|
||||
export async function captureClaudeCliUsageText(timeoutMs = 12_000): Promise<string> {
|
||||
const command = buildClaudeCliShellProbeCommand();
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("sh", ["-c", command], {
|
||||
env: createClaudeQuotaEnv(),
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const output = `${stdout}${stderr}`;
|
||||
const cleaned = cleanTerminalText(output);
|
||||
if (usageOutputLooksComplete(cleaned)) return output;
|
||||
throw new Error("Claude CLI usage probe ended before rendering usage.");
|
||||
} catch (error) {
|
||||
const stdout =
|
||||
typeof error === "object" && error !== null && "stdout" in error && typeof error.stdout === "string"
|
||||
? error.stdout
|
||||
: "";
|
||||
const stderr =
|
||||
typeof error === "object" && error !== null && "stderr" in error && typeof error.stderr === "string"
|
||||
? error.stderr
|
||||
: "";
|
||||
const output = `${stdout}${stderr}`;
|
||||
const cleaned = cleanTerminalText(output);
|
||||
if (usageOutputLooksComplete(cleaned)) return output;
|
||||
if (usageOutputLooksRelevant(cleaned)) {
|
||||
throw new Error("Claude CLI usage probe ended before rendering usage.");
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClaudeCliQuota(): Promise<QuotaWindow[]> {
|
||||
const rawText = await captureClaudeCliUsageText();
|
||||
return parseClaudeCliUsageText(rawText);
|
||||
}
|
||||
|
||||
function formatProviderError(source: string, error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `${source}: ${message}`;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const authStatus = await readClaudeAuthStatus();
|
||||
const authDescription = describeClaudeSubscriptionAuth(authStatus);
|
||||
const token = await readClaudeToken();
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const windows = await fetchClaudeQuota(token);
|
||||
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_OAUTH, ok: true, windows };
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("Anthropic OAuth usage", error));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const windows = await fetchClaudeCliQuota();
|
||||
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_CLI, ok: true, windows };
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("Claude CLI /usage", error));
|
||||
}
|
||||
|
||||
if (hasNonEmptyProcessEnv("ANTHROPIC_API_KEY") && !authDescription) {
|
||||
return {
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error:
|
||||
errors[0]
|
||||
?? "ANTHROPIC_API_KEY is set and no local Claude subscription session is available for quota polling",
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (authDescription) {
|
||||
return {
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error:
|
||||
errors.length > 0
|
||||
? `${authDescription}, but quota polling failed (${errors.join("; ")})`
|
||||
: `${authDescription}, but Paperclip could not load subscription quota data`,
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "anthropic",
|
||||
ok: false,
|
||||
error: errors[0] ?? "no local claude auth token",
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveClaudeSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".claude", "skills");
|
||||
}
|
||||
|
||||
async function buildClaudeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const skillsHome = resolveClaudeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired: desiredSet.has(entry.key),
|
||||
managed: true,
|
||||
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Will be mounted into the ephemeral Claude skill directory on the next run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: undefined,
|
||||
targetPath: undefined,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: "~/.claude/skills",
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: "Installed outside Paperclip management in the Claude skills home.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
adapterType: "claude_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listClaudeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildClaudeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncClaudeSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildClaudeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveClaudeDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string): string | null {
|
||||
const raw = firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "claude");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "claude_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "claude_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "claude_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "claude_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configApiKey = env.ANTHROPIC_API_KEY;
|
||||
const hostApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) {
|
||||
const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "claude_anthropic_api_key_overrides_subscription",
|
||||
level: "warn",
|
||||
message:
|
||||
"ANTHROPIC_API_KEY is set. Claude will use API-key auth instead of subscription credentials.",
|
||||
detail: `Detected in ${source}.`,
|
||||
hint: "Unset ANTHROPIC_API_KEY if you want subscription-based Claude login behavior.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "claude_subscription_mode_possible",
|
||||
level: "info",
|
||||
message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "claude_cwd_invalid" && check.code !== "claude_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "claude")) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `claude`.",
|
||||
detail: command,
|
||||
hint: "Use the `claude` CLI command to run the automatic login and installation probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, "").trim();
|
||||
const effort = asString(config.effort, "").trim();
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
if (chrome) args.push("--chrome");
|
||||
if (model) args.push("--model", model);
|
||||
if (effort) args.push("--effort", effort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsedStream = parseClaudeStreamJson(probe.stdout);
|
||||
const parsed = parsedStream.resultJson;
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr);
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Claude hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Claude can run `Respond with hello` from this directory manually.",
|
||||
});
|
||||
} else if (loginMeta.requiresLogin) {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Claude CLI is installed, but login is required.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: loginMeta.loginUrl
|
||||
? `Run \`claude login\` and complete sign-in at ${loginMeta.loginUrl}, then retry.`
|
||||
: "Run `claude login` in this environment, then retry the probe.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsedStream.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "claude_hello_probe_passed" : "claude_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Claude hello probe succeeded."
|
||||
: "Claude probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try the probe manually (`claude --print - --output-format stream-json --verbose`) and prompt `Respond with hello`.",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "claude_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Claude hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `claude --print - --output-format stream-json --verbose` manually in this directory and prompt `Respond with hello` to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
||||
if (v.chrome) ac.chrome = true;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseClaudeStdoutLine } from "./parse-stdout.js";
|
||||
export { buildClaudeLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
return [
|
||||
{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
||||
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message = asRecord(parsed.message) ?? {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const blockRaw of content) {
|
||||
const block = asRecord(blockRaw);
|
||||
if (!block) continue;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
} else if (blockType === "thinking") {
|
||||
const text = typeof block.thinking === "string" ? block.thinking : "";
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
} else if (blockType === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: typeof block.name === "string" ? block.name : "unknown",
|
||||
toolUseId:
|
||||
typeof block.id === "string"
|
||||
? block.id
|
||||
: typeof block.tool_use_id === "string"
|
||||
? block.tool_use_id
|
||||
: undefined,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
const message = asRecord(parsed.message) ?? {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const blockRaw of content) {
|
||||
const block = asRecord(blockRaw);
|
||||
if (!block) continue;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) entries.push({ kind: "user", ts, text });
|
||||
} else if (blockType === "tool_result") {
|
||||
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
||||
const isError = block.is_error === true;
|
||||
let text = "";
|
||||
if (typeof block.content === "string") {
|
||||
text = block.content;
|
||||
} else if (Array.isArray(block.content)) {
|
||||
const parts: string[] = [];
|
||||
for (const part of block.content) {
|
||||
const p = asRecord(part);
|
||||
if (p && typeof p.text === "string") parts.push(p.text);
|
||||
}
|
||||
text = parts.join("\n");
|
||||
}
|
||||
entries.push({ kind: "tool_result", ts, toolUseId, content: text, isError });
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) return entries;
|
||||
// fall through to stdout for user messages without recognized blocks
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage) ?? {};
|
||||
const inputTokens = asNumber(usage.input_tokens);
|
||||
const outputTokens = asNumber(usage.output_tokens);
|
||||
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
||||
const costUsd = asNumber(parsed.total_cost_usd);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||
const text = typeof parsed.result === "string" ? parsed.result : "";
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd,
|
||||
subtype,
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/codex-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function printItemStarted(item: Record<string, unknown>): boolean {
|
||||
const itemType = asString(item.type);
|
||||
if (itemType === "command_execution") {
|
||||
const command = asString(item.command);
|
||||
console.log(pc.yellow("tool_call: command_execution"));
|
||||
if (command) console.log(pc.gray(command));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
const name = asString(item.name, "unknown");
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (item.input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(item.input)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function printItemCompleted(item: Record<string, unknown>): boolean {
|
||||
const itemType = asString(item.type);
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
const name = asString(item.name, "unknown");
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (item.input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(item.input)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "command_execution") {
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
const isError =
|
||||
(exitCode !== null && exitCode !== 0) ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
|
||||
const summaryParts = [
|
||||
"tool_result: command_execution",
|
||||
command ? `command="${command}"` : "",
|
||||
status ? `status=${status}` : "",
|
||||
exitCode !== null ? `exit_code=${exitCode}` : "",
|
||||
].filter(Boolean);
|
||||
console.log((isError ? pc.red : pc.cyan)(summaryParts.join(" ")));
|
||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "file_change") {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const entries = changes
|
||||
.map((changeRaw) => asRecord(changeRaw))
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
const preview = entries.length > 0 ? entries.slice(0, 6).join(", ") : "none";
|
||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
||||
console.log(pc.cyan(`file_change: ${preview}${more}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "error") {
|
||||
const message = errorText(item.message ?? item.error ?? item);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_result") {
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
const text = asString(item.content) || asString(item.result) || asString(item.output);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (text) console.log((isError ? pc.red : pc.gray)(text));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function printCodexStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = asString(parsed.thread_id);
|
||||
const model = asString(parsed.model);
|
||||
const details = [threadId ? `session: ${threadId}` : "", model ? `model: ${model}` : ""].filter(Boolean).join(", ");
|
||||
console.log(pc.blue(`Codex thread started${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.started") {
|
||||
console.log(pc.blue("turn started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "item.started" || type === "item.completed") {
|
||||
const item = asRecord(parsed.item);
|
||||
if (item) {
|
||||
const handled =
|
||||
type === "item.started"
|
||||
? printItemStarted(item)
|
||||
: printItemCompleted(item);
|
||||
if (!handled) {
|
||||
const itemType = asString(item.type, "unknown");
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
console.log(pc.gray(`${type}: ${itemType}${meta ? ` (${meta})` : ""}`));
|
||||
}
|
||||
} else {
|
||||
console.log(pc.gray(type));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens);
|
||||
const output = asNumber(usage?.output_tokens);
|
||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const cost = asNumber(parsed.total_cost_usd);
|
||||
const isError = parsed.is_error === true;
|
||||
const subtype = asString(parsed.subtype);
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||
|
||||
console.log(
|
||||
pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`),
|
||||
);
|
||||
if (subtype || isError || errors.length > 0) {
|
||||
console.log(
|
||||
pc.red(`result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`),
|
||||
);
|
||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens);
|
||||
const output = asNumber(usage?.output_tokens);
|
||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
console.log(pc.red(`turn failed${message ? `: ${message}` : ""}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printCodexStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
fetchCodexQuota,
|
||||
fetchCodexRpcQuota,
|
||||
getQuotaWindows,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
} from "../server/quota.js";
|
||||
|
||||
interface ProbeArgs {
|
||||
json: boolean;
|
||||
rpcOnly: boolean;
|
||||
whamOnly: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): ProbeArgs {
|
||||
return {
|
||||
json: argv.includes("--json"),
|
||||
rpcOnly: argv.includes("--rpc-only"),
|
||||
whamOnly: argv.includes("--wham-only"),
|
||||
};
|
||||
}
|
||||
|
||||
function stringifyError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.rpcOnly && args.whamOnly) {
|
||||
throw new Error("Choose either --rpc-only or --wham-only, not both.");
|
||||
}
|
||||
|
||||
const auth = await readCodexAuthInfo();
|
||||
const token = await readCodexToken();
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
auth,
|
||||
tokenAvailable: token != null,
|
||||
};
|
||||
|
||||
if (!args.whamOnly) {
|
||||
try {
|
||||
result.rpc = {
|
||||
ok: true,
|
||||
...(await fetchCodexRpcQuota()),
|
||||
};
|
||||
} catch (error) {
|
||||
result.rpc = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.rpcOnly) {
|
||||
if (!token) {
|
||||
result.wham = {
|
||||
ok: false,
|
||||
error: "No local Codex auth token found in ~/.codex/auth.json.",
|
||||
windows: [],
|
||||
};
|
||||
} else {
|
||||
try {
|
||||
result.wham = {
|
||||
ok: true,
|
||||
windows: await fetchCodexQuota(token.token, token.accountId),
|
||||
};
|
||||
} catch (error) {
|
||||
result.wham = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.rpcOnly && !args.whamOnly) {
|
||||
try {
|
||||
result.aggregated = await getQuotaWindows();
|
||||
} catch (error) {
|
||||
result.aggregated = {
|
||||
ok: false,
|
||||
error: stringifyError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rpcOk = (result.rpc as { ok?: boolean } | undefined)?.ok === true;
|
||||
const whamOk = (result.wham as { ok?: boolean } | undefined)?.ok === true;
|
||||
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
|
||||
const ok = rpcOk || whamOk || aggregatedOk;
|
||||
|
||||
if (args.json || process.stdout.isTTY === false) {
|
||||
console.log(JSON.stringify({ ok, ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`timestamp: ${result.timestamp}`);
|
||||
console.log(`auth: ${JSON.stringify(auth)}`);
|
||||
console.log(`tokenAvailable: ${token != null}`);
|
||||
if (result.rpc) console.log(`rpc: ${JSON.stringify(result.rpc, null, 2)}`);
|
||||
if (result.wham) console.log(`wham: ${JSON.stringify(result.wham, null, 2)}`);
|
||||
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,49 @@
|
||||
export const type = "codex_local";
|
||||
export const label = "Codex (local)";
|
||||
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
|
||||
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
|
||||
|
||||
export const models = [
|
||||
{ id: "gpt-5.4", label: "gpt-5.4" },
|
||||
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
|
||||
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
|
||||
{ id: "gpt-5", label: "gpt-5" },
|
||||
{ id: "o3", label: "o3" },
|
||||
{ id: "o4-mini", label: "o4-mini" },
|
||||
{ id: "gpt-5-mini", label: "gpt-5-mini" },
|
||||
{ id: "gpt-5-nano", label: "gpt-5-nano" },
|
||||
{ id: "o3-mini", label: "o3-mini" },
|
||||
{ id: "codex-mini-latest", label: "Codex Mini" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# codex_local agent configuration
|
||||
|
||||
Adapter: codex_local
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
|
||||
- model (string, optional): Codex model id
|
||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- search (boolean, optional): run codex with --search
|
||||
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
|
||||
- command (string, optional): defaults to "codex"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run.
|
||||
- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath.
|
||||
- Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home.
|
||||
- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex).
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
- When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
|
||||
`;
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||
|
||||
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
|
||||
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
|
||||
const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
|
||||
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
|
||||
|
||||
function nonEmpty(value: string | undefined): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function pathExists(candidate: string): Promise<boolean> {
|
||||
return fs.access(candidate).then(() => true).catch(() => false);
|
||||
}
|
||||
|
||||
export function resolveSharedCodexHomeDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const fromEnv = nonEmpty(env.CODEX_HOME);
|
||||
return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
|
||||
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
|
||||
}
|
||||
|
||||
export function resolveManagedCodexHomeDir(
|
||||
env: NodeJS.ProcessEnv,
|
||||
companyId?: string,
|
||||
): string {
|
||||
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
|
||||
const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
|
||||
return companyId
|
||||
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
|
||||
: path.resolve(paperclipHome, "instances", instanceId, "codex-home");
|
||||
}
|
||||
|
||||
async function ensureParentDir(target: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
}
|
||||
|
||||
async function ensureSymlink(target: string, source: string): Promise<void> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing) {
|
||||
await ensureParentDir(target);
|
||||
await fs.symlink(source, target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existing.isSymbolicLink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) return;
|
||||
|
||||
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||
if (resolvedLinkedPath === source) return;
|
||||
|
||||
await fs.unlink(target);
|
||||
await fs.symlink(source, target);
|
||||
}
|
||||
|
||||
async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) return;
|
||||
await ensureParentDir(target);
|
||||
await fs.copyFile(source, target);
|
||||
}
|
||||
|
||||
export async function prepareManagedCodexHome(
|
||||
env: NodeJS.ProcessEnv,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
companyId?: string,
|
||||
): Promise<string> {
|
||||
const targetHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
|
||||
const sourceHome = resolveSharedCodexHomeDir(env);
|
||||
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
|
||||
|
||||
await fs.mkdir(targetHome, { recursive: true });
|
||||
|
||||
for (const name of SYMLINKED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureSymlink(path.join(targetHome, name), source);
|
||||
}
|
||||
|
||||
for (const name of COPIED_SHARED_FILES) {
|
||||
const source = path.join(sourceHome, name);
|
||||
if (!(await pathExists(source))) continue;
|
||||
await ensureCopiedFile(path.join(targetHome, name), source);
|
||||
}
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
||||
);
|
||||
return targetHome;
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
renderTemplate,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js";
|
||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||
|
||||
function stripCodexRolloutNoise(text: string): string {
|
||||
const parts = text.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
kept.push(part);
|
||||
continue;
|
||||
}
|
||||
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function resolveCodexBiller(env: Record<string, string>, billingType: "api" | "subscription"): string {
|
||||
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, "openai");
|
||||
if (openAiCompatibleBiller === "openrouter") return "openrouter";
|
||||
return billingType === "subscription" ? "chatgpt" : openAiCompatibleBiller ?? "openai";
|
||||
}
|
||||
|
||||
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
|
||||
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
|
||||
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
|
||||
pathExists(path.join(candidate, "package.json")),
|
||||
pathExists(path.join(candidate, "server")),
|
||||
pathExists(path.join(candidate, "packages", "adapter-utils")),
|
||||
]);
|
||||
|
||||
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
|
||||
}
|
||||
|
||||
async function isLikelyPaperclipRuntimeSkillPath(
|
||||
candidate: string,
|
||||
skillName: string,
|
||||
options: { requireSkillMarkdown?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
if (path.basename(candidate) !== skillName) return false;
|
||||
const skillsRoot = path.dirname(candidate);
|
||||
if (path.basename(skillsRoot) !== "skills") return false;
|
||||
if (options.requireSkillMarkdown !== false && !(await pathExists(path.join(candidate, "SKILL.md")))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cursor = path.dirname(skillsRoot);
|
||||
for (let depth = 0; depth < 6; depth += 1) {
|
||||
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) break;
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pruneBrokenUnavailablePaperclipSkillSymlinks(
|
||||
skillsHome: string,
|
||||
allowedSkillNames: Iterable<string>,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
) {
|
||||
const allowed = new Set(Array.from(allowedSkillNames));
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (allowed.has(entry.name) || !entry.isSymbolicLink()) continue;
|
||||
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) continue;
|
||||
|
||||
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||
if (await pathExists(resolvedLinkedPath)) continue;
|
||||
if (
|
||||
!(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.name, {
|
||||
requireSkillMarkdown: false,
|
||||
}))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.unlink(target).catch(() => {});
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexWorkspaceSkillsDir(cwd: string): string {
|
||||
return path.join(cwd, ".agents", "skills");
|
||||
}
|
||||
|
||||
type EnsureCodexSkillsInjectedOptions = {
|
||||
skillsHome?: string;
|
||||
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||
desiredSkillNames?: string[];
|
||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function ensureCodexSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
options: EnsureCodexSkillsInjectedOptions = {},
|
||||
) {
|
||||
const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir);
|
||||
const desiredSkillNames =
|
||||
options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.key);
|
||||
const desiredSet = new Set(desiredSkillNames);
|
||||
const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? resolveCodexWorkspaceSkillsDir(process.cwd());
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const linkSkill = options.linkSkill;
|
||||
for (const entry of skillsEntries) {
|
||||
const target = path.join(skillsHome, entry.runtimeName);
|
||||
|
||||
try {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing?.isSymbolicLink()) {
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
const resolvedLinkedPath = linkedPath
|
||||
? path.resolve(path.dirname(target), linkedPath)
|
||||
: null;
|
||||
if (
|
||||
resolvedLinkedPath &&
|
||||
resolvedLinkedPath !== entry.source &&
|
||||
(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName))
|
||||
) {
|
||||
await fs.unlink(target);
|
||||
if (linkSkill) {
|
||||
await linkSkill(entry.source, target);
|
||||
} else {
|
||||
await fs.symlink(entry.source, target);
|
||||
}
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Repaired Codex skill "${entry.runtimeName}" into ${skillsHome}\n`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
||||
if (result === "skipped") continue;
|
||||
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.runtimeName}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to inject Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pruneBrokenUnavailablePaperclipSkillSymlinks(
|
||||
skillsHome,
|
||||
skillsEntries.map((entry) => entry.runtimeName),
|
||||
onLog,
|
||||
);
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
const modelReasoningEffort = asString(
|
||||
config.modelReasoningEffort,
|
||||
asString(config.reasoningEffort, ""),
|
||||
);
|
||||
const search = asBoolean(config.search, false);
|
||||
const bypass = asBoolean(
|
||||
config.dangerouslyBypassApprovalsAndSandbox,
|
||||
asBoolean(config.dangerouslyBypassSandbox, false),
|
||||
);
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
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 runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
||||
? context.paperclipRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
||||
? context.paperclipRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
const envConfig = parseObject(config.env);
|
||||
const configuredCodexHome =
|
||||
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
||||
? path.resolve(envConfig.CODEX_HOME.trim())
|
||||
: null;
|
||||
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const preparedManagedCodexHome =
|
||||
configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId);
|
||||
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
|
||||
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
|
||||
await fs.mkdir(effectiveCodexHome, { recursive: true });
|
||||
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
|
||||
await ensureCodexSkillsInjected(
|
||||
onLog,
|
||||
{
|
||||
skillsHome: codexWorkspaceSkillsDir,
|
||||
skillsEntries: codexSkillEntries,
|
||||
desiredSkillNames,
|
||||
},
|
||||
);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.CODEX_HOME = effectiveCodexHome;
|
||||
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)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCodexBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
let instructionsChars = 0;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const repoAgentsNote =
|
||||
"Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.";
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) {
|
||||
return [repoAgentsNote];
|
||||
}
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
repoAgentsNote,
|
||||
];
|
||||
})();
|
||||
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 renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["exec", "--json"];
|
||||
if (search) args.unshift("--search");
|
||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (model) args.push("--model", model);
|
||||
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
|
||||
else args.push("-");
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "codex_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stderr") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
const cleaned = stripCodexRolloutNoise(chunk);
|
||||
if (!cleaned.trim()) return;
|
||||
await onLog(stream, cleaned);
|
||||
},
|
||||
});
|
||||
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
||||
return {
|
||||
proc: {
|
||||
...proc,
|
||||
stderr: cleanedStderr,
|
||||
},
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseCodexJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType<typeof parseCodexJsonl> },
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "openai",
|
||||
biller: resolveCodexBiller(effectiveEnv, billingType),
|
||||
model,
|
||||
billingType,
|
||||
costUsd: null,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export { execute, ensureCodexSkillsInjected } from "./execute.js";
|
||||
export { listCodexSkills, syncCodexSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
fetchCodexRpcQuota,
|
||||
mapCodexRpcQuota,
|
||||
secondsToWindowLabel,
|
||||
fetchWithTimeout,
|
||||
codexHomeDir,
|
||||
} from "./quota.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
export function parseCodexJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
if (type === "thread.started") {
|
||||
sessionId = asString(event.thread_id, sessionId ?? "") || sessionId;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const msg = asString(event.message, "").trim();
|
||||
if (msg) errorMessage = msg;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "item.completed") {
|
||||
const item = parseObject(event.item);
|
||||
if (asString(item.type, "") === "agent_message") {
|
||||
const text = asString(item.text, "");
|
||||
if (text) messages.push(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usageObj = parseObject(event.usage);
|
||||
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
||||
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
||||
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const err = parseObject(event.error);
|
||||
const msg = asString(err.message, "").trim();
|
||||
if (msg) errorMessage = msg;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCodexUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found|missing rollout path for thread|state db missing rollout path/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
|
||||
|
||||
const CODEX_USAGE_SOURCE_RPC = "codex-rpc";
|
||||
const CODEX_USAGE_SOURCE_WHAM = "codex-wham";
|
||||
|
||||
export function codexHomeDir(): string {
|
||||
const fromEnv = process.env.CODEX_HOME;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
interface CodexLegacyAuthFile {
|
||||
accessToken?: string | null;
|
||||
accountId?: string | null;
|
||||
}
|
||||
|
||||
interface CodexTokenBlock {
|
||||
id_token?: string | null;
|
||||
access_token?: string | null;
|
||||
refresh_token?: string | null;
|
||||
account_id?: string | null;
|
||||
}
|
||||
|
||||
interface CodexModernAuthFile {
|
||||
OPENAI_API_KEY?: string | null;
|
||||
tokens?: CodexTokenBlock | null;
|
||||
last_refresh?: string | null;
|
||||
}
|
||||
|
||||
export interface CodexAuthInfo {
|
||||
accessToken: string;
|
||||
accountId: string | null;
|
||||
refreshToken: string | null;
|
||||
idToken: string | null;
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
lastRefresh: string | null;
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): string | null {
|
||||
try {
|
||||
let normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const remainder = normalized.length % 4;
|
||||
if (remainder > 0) normalized += "=".repeat(4 - remainder);
|
||||
return Buffer.from(normalized, "base64").toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null {
|
||||
if (typeof token !== "string" || token.trim().length === 0) return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return null;
|
||||
const decoded = base64UrlDecode(parts[1] ?? "");
|
||||
if (!decoded) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(decoded) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readNestedString(record: Record<string, unknown>, pathSegments: string[]): string | null {
|
||||
let current: unknown = record;
|
||||
for (const segment of pathSegments) {
|
||||
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return typeof current === "string" && current.trim().length > 0 ? current.trim() : null;
|
||||
}
|
||||
|
||||
function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string | null): {
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
} {
|
||||
const payloads = [decodeJwtPayload(idToken), decodeJwtPayload(accessToken)].filter(
|
||||
(value): value is Record<string, unknown> => value != null,
|
||||
);
|
||||
for (const payload of payloads) {
|
||||
const directEmail = typeof payload.email === "string" ? payload.email : null;
|
||||
const authBlock =
|
||||
typeof payload["https://api.openai.com/auth"] === "object" &&
|
||||
payload["https://api.openai.com/auth"] !== null &&
|
||||
!Array.isArray(payload["https://api.openai.com/auth"])
|
||||
? payload["https://api.openai.com/auth"] as Record<string, unknown>
|
||||
: null;
|
||||
const profileBlock =
|
||||
typeof payload["https://api.openai.com/profile"] === "object" &&
|
||||
payload["https://api.openai.com/profile"] !== null &&
|
||||
!Array.isArray(payload["https://api.openai.com/profile"])
|
||||
? payload["https://api.openai.com/profile"] as Record<string, unknown>
|
||||
: null;
|
||||
const email =
|
||||
directEmail
|
||||
?? (typeof profileBlock?.email === "string" ? profileBlock.email : null)
|
||||
?? (typeof authBlock?.chatgpt_user_email === "string" ? authBlock.chatgpt_user_email : null);
|
||||
const planType =
|
||||
typeof authBlock?.chatgpt_plan_type === "string" ? authBlock.chatgpt_plan_type : null;
|
||||
if (email || planType) return { email: email ?? null, planType };
|
||||
}
|
||||
return { email: null, planType: null };
|
||||
}
|
||||
|
||||
export async function readCodexAuthInfo(codexHome?: string): Promise<CodexAuthInfo | null> {
|
||||
const authPath = path.join(codexHome ?? codexHomeDir(), "auth.json");
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(authPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const modern = obj as CodexModernAuthFile;
|
||||
const legacy = obj as CodexLegacyAuthFile;
|
||||
|
||||
const accessToken =
|
||||
legacy.accessToken
|
||||
?? modern.tokens?.access_token
|
||||
?? readNestedString(obj, ["tokens", "access_token"]);
|
||||
if (typeof accessToken !== "string" || accessToken.length === 0) return null;
|
||||
|
||||
const accountId =
|
||||
legacy.accountId
|
||||
?? modern.tokens?.account_id
|
||||
?? readNestedString(obj, ["tokens", "account_id"]);
|
||||
const refreshToken =
|
||||
modern.tokens?.refresh_token
|
||||
?? readNestedString(obj, ["tokens", "refresh_token"]);
|
||||
const idToken =
|
||||
modern.tokens?.id_token
|
||||
?? readNestedString(obj, ["tokens", "id_token"]);
|
||||
const { email, planType } = parsePlanAndEmailFromToken(idToken, accessToken);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accountId:
|
||||
typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : null,
|
||||
refreshToken:
|
||||
typeof refreshToken === "string" && refreshToken.trim().length > 0 ? refreshToken.trim() : null,
|
||||
idToken:
|
||||
typeof idToken === "string" && idToken.trim().length > 0 ? idToken.trim() : null,
|
||||
email,
|
||||
planType,
|
||||
lastRefresh:
|
||||
typeof modern.last_refresh === "string" && modern.last_refresh.trim().length > 0
|
||||
? modern.last_refresh.trim()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
|
||||
const auth = await readCodexAuthInfo();
|
||||
if (!auth) return null;
|
||||
return { token: auth.accessToken, accountId: auth.accountId };
|
||||
}
|
||||
|
||||
interface WhamWindow {
|
||||
used_percent?: number | null;
|
||||
limit_window_seconds?: number | null;
|
||||
reset_at?: string | number | null;
|
||||
}
|
||||
|
||||
interface WhamCredits {
|
||||
balance?: number | null;
|
||||
unlimited?: boolean | null;
|
||||
}
|
||||
|
||||
interface WhamUsageResponse {
|
||||
plan_type?: string | null;
|
||||
rate_limit?: {
|
||||
primary_window?: WhamWindow | null;
|
||||
secondary_window?: WhamWindow | null;
|
||||
} | null;
|
||||
credits?: WhamCredits | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a window duration in seconds to a human-readable label.
|
||||
* Falls back to the provided fallback string when seconds is null/undefined.
|
||||
*/
|
||||
export function secondsToWindowLabel(
|
||||
seconds: number | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (seconds == null) return fallback;
|
||||
const hours = seconds / 3600;
|
||||
if (hours < 6) return "5h";
|
||||
if (hours <= 24) return "24h";
|
||||
if (hours <= 168) return "7d";
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
ms = 8000,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCodexUsedPercent(rawPct: number | null | undefined): number | null {
|
||||
if (rawPct == null) return null;
|
||||
return Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct));
|
||||
}
|
||||
|
||||
export async function fetchCodexQuota(
|
||||
token: string,
|
||||
accountId: string | null,
|
||||
): Promise<QuotaWindow[]> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
||||
|
||||
const resp = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { headers });
|
||||
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
|
||||
const body = (await resp.json()) as WhamUsageResponse;
|
||||
const windows: QuotaWindow[] = [];
|
||||
|
||||
const rateLimit = body.rate_limit;
|
||||
if (rateLimit?.primary_window != null) {
|
||||
const w = rateLimit.primary_window;
|
||||
windows.push({
|
||||
label: "5h limit",
|
||||
usedPercent: normalizeCodexUsedPercent(w.used_percent),
|
||||
resetsAt:
|
||||
typeof w.reset_at === "number"
|
||||
? unixSecondsToIso(w.reset_at)
|
||||
: (w.reset_at ?? null),
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (rateLimit?.secondary_window != null) {
|
||||
const w = rateLimit.secondary_window;
|
||||
windows.push({
|
||||
label: "Weekly limit",
|
||||
usedPercent: normalizeCodexUsedPercent(w.used_percent),
|
||||
resetsAt:
|
||||
typeof w.reset_at === "number"
|
||||
? unixSecondsToIso(w.reset_at)
|
||||
: (w.reset_at ?? null),
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
if (body.credits != null && body.credits.unlimited !== true) {
|
||||
const balance = body.credits.balance;
|
||||
const valueLabel = balance != null ? `$${(balance / 100).toFixed(2)} remaining` : "N/A";
|
||||
windows.push({
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
interface CodexRpcWindow {
|
||||
usedPercent?: number | null;
|
||||
windowDurationMins?: number | null;
|
||||
resetsAt?: number | null;
|
||||
}
|
||||
|
||||
interface CodexRpcCredits {
|
||||
hasCredits?: boolean | null;
|
||||
unlimited?: boolean | null;
|
||||
balance?: string | number | null;
|
||||
}
|
||||
|
||||
interface CodexRpcLimit {
|
||||
limitId?: string | null;
|
||||
limitName?: string | null;
|
||||
primary?: CodexRpcWindow | null;
|
||||
secondary?: CodexRpcWindow | null;
|
||||
credits?: CodexRpcCredits | null;
|
||||
planType?: string | null;
|
||||
}
|
||||
|
||||
interface CodexRpcRateLimitsResult {
|
||||
rateLimits?: CodexRpcLimit | null;
|
||||
rateLimitsByLimitId?: Record<string, CodexRpcLimit> | null;
|
||||
}
|
||||
|
||||
interface CodexRpcAccountResult {
|
||||
account?: {
|
||||
type?: string | null;
|
||||
email?: string | null;
|
||||
planType?: string | null;
|
||||
} | null;
|
||||
requiresOpenaiAuth?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexRpcQuotaSnapshot {
|
||||
windows: QuotaWindow[];
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
}
|
||||
|
||||
function unixSecondsToIso(value: number | null | undefined): string | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function buildCodexRpcWindow(label: string, window: CodexRpcWindow | null | undefined): QuotaWindow | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
label,
|
||||
usedPercent: normalizeCodexUsedPercent(window.usedPercent),
|
||||
resetsAt: unixSecondsToIso(window.resetsAt),
|
||||
valueLabel: null,
|
||||
detail: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCreditBalance(value: string | number | null | undefined): string | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return `$${value.toFixed(2)} remaining`;
|
||||
}
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return `$${parsed.toFixed(2)} remaining`;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapCodexRpcQuota(result: CodexRpcRateLimitsResult, account?: CodexRpcAccountResult | null): CodexRpcQuotaSnapshot {
|
||||
const windows: QuotaWindow[] = [];
|
||||
const limitOrder = ["codex"];
|
||||
const limitsById = result.rateLimitsByLimitId ?? {};
|
||||
for (const key of Object.keys(limitsById)) {
|
||||
if (!limitOrder.includes(key)) limitOrder.push(key);
|
||||
}
|
||||
|
||||
const rootLimit = result.rateLimits ?? null;
|
||||
const allLimits = new Map<string, CodexRpcLimit>();
|
||||
if (rootLimit?.limitId) allLimits.set(rootLimit.limitId, rootLimit);
|
||||
for (const [key, value] of Object.entries(limitsById)) {
|
||||
allLimits.set(key, value);
|
||||
}
|
||||
if (!allLimits.has("codex") && rootLimit) allLimits.set("codex", rootLimit);
|
||||
|
||||
for (const limitId of limitOrder) {
|
||||
const limit = allLimits.get(limitId);
|
||||
if (!limit) continue;
|
||||
const prefix =
|
||||
limitId === "codex"
|
||||
? ""
|
||||
: `${limit.limitName ?? limitId} · `;
|
||||
const primary = buildCodexRpcWindow(`${prefix}5h limit`, limit.primary);
|
||||
if (primary) windows.push(primary);
|
||||
const secondary = buildCodexRpcWindow(`${prefix}Weekly limit`, limit.secondary);
|
||||
if (secondary) windows.push(secondary);
|
||||
if (limitId === "codex" && limit.credits && limit.credits.unlimited !== true) {
|
||||
windows.push({
|
||||
label: "Credits",
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel: parseCreditBalance(limit.credits.balance) ?? "N/A",
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
windows,
|
||||
email:
|
||||
typeof account?.account?.email === "string" && account.account.email.trim().length > 0
|
||||
? account.account.email.trim()
|
||||
: null,
|
||||
planType:
|
||||
typeof account?.account?.planType === "string" && account.account.planType.trim().length > 0
|
||||
? account.account.planType.trim()
|
||||
: (typeof rootLimit?.planType === "string" && rootLimit.planType.trim().length > 0 ? rootLimit.planType.trim() : null),
|
||||
};
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: Record<string, unknown>) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
class CodexRpcClient {
|
||||
private proc = spawn(
|
||||
"codex",
|
||||
["-s", "read-only", "-a", "untrusted", "app-server"],
|
||||
{ stdio: ["pipe", "pipe", "pipe"], env: process.env },
|
||||
);
|
||||
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private stderr = "";
|
||||
|
||||
constructor() {
|
||||
this.proc.stdout.setEncoding("utf8");
|
||||
this.proc.stderr.setEncoding("utf8");
|
||||
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
|
||||
this.proc.stderr.on("data", (chunk: string) => {
|
||||
this.stderr += chunk;
|
||||
});
|
||||
this.proc.on("exit", () => {
|
||||
for (const request of this.pending.values()) {
|
||||
clearTimeout(request.timer);
|
||||
request.reject(new Error(this.stderr.trim() || "codex app-server closed unexpectedly"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private onStdout(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
while (true) {
|
||||
const newlineIndex = this.buffer.indexOf("\n");
|
||||
if (newlineIndex < 0) break;
|
||||
const line = this.buffer.slice(0, newlineIndex).trim();
|
||||
this.buffer = this.buffer.slice(newlineIndex + 1);
|
||||
if (!line) continue;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const id = typeof parsed.id === "number" ? parsed.id : null;
|
||||
if (id == null) continue;
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) continue;
|
||||
this.pending.delete(id);
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
private request(method: string, params: Record<string, unknown> = {}, timeoutMs = 6_000): Promise<Record<string, unknown>> {
|
||||
const id = this.nextId++;
|
||||
const payload = JSON.stringify({ id, method, params }) + "\n";
|
||||
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`codex app-server timed out on ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.proc.stdin.write(payload);
|
||||
});
|
||||
}
|
||||
|
||||
private notify(method: string, params: Record<string, unknown> = {}) {
|
||||
this.proc.stdin.write(JSON.stringify({ method, params }) + "\n");
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.request("initialize", {
|
||||
clientInfo: {
|
||||
name: "paperclip",
|
||||
version: "0.0.0",
|
||||
},
|
||||
});
|
||||
this.notify("initialized", {});
|
||||
}
|
||||
|
||||
async fetchRateLimits(): Promise<CodexRpcRateLimitsResult> {
|
||||
const message = await this.request("account/rateLimits/read");
|
||||
return (message.result as CodexRpcRateLimitsResult | undefined) ?? {};
|
||||
}
|
||||
|
||||
async fetchAccount(): Promise<CodexRpcAccountResult | null> {
|
||||
try {
|
||||
const message = await this.request("account/read");
|
||||
return (message.result as CodexRpcAccountResult | undefined) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.proc.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCodexRpcQuota(): Promise<CodexRpcQuotaSnapshot> {
|
||||
const client = new CodexRpcClient();
|
||||
try {
|
||||
await client.initialize();
|
||||
const [limits, account] = await Promise.all([
|
||||
client.fetchRateLimits(),
|
||||
client.fetchAccount(),
|
||||
]);
|
||||
return mapCodexRpcQuota(limits, account);
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
function formatProviderError(source: string, error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return `${source}: ${message}`;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const rpc = await fetchCodexRpcQuota();
|
||||
if (rpc.windows.length > 0) {
|
||||
return { provider: "openai", source: CODEX_USAGE_SOURCE_RPC, ok: true, windows: rpc.windows };
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("Codex app-server", error));
|
||||
}
|
||||
|
||||
const auth = await readCodexToken();
|
||||
if (auth) {
|
||||
try {
|
||||
const windows = await fetchCodexQuota(auth.token, auth.accountId);
|
||||
return { provider: "openai", source: CODEX_USAGE_SOURCE_WHAM, ok: true, windows };
|
||||
} catch (error) {
|
||||
errors.push(formatProviderError("ChatGPT WHAM usage", error));
|
||||
}
|
||||
} else {
|
||||
errors.push("no local codex auth token");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "openai",
|
||||
ok: false,
|
||||
error: errors.join("; "),
|
||||
windows: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillEntry,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function buildCodexSkillSnapshot(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
|
||||
key: entry.key,
|
||||
runtimeName: entry.runtimeName,
|
||||
desired: desiredSet.has(entry.key),
|
||||
managed: true,
|
||||
state: desiredSet.has(entry.key) ? "configured" : "available",
|
||||
origin: entry.required ? "paperclip_required" : "company_managed",
|
||||
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
||||
readOnly: false,
|
||||
sourcePath: entry.source,
|
||||
targetPath: null,
|
||||
detail: desiredSet.has(entry.key)
|
||||
? "Will be linked into the workspace .agents/skills directory on the next run."
|
||||
: null,
|
||||
required: Boolean(entry.required),
|
||||
requiredReason: entry.requiredReason ?? null,
|
||||
}));
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
adapterType: "codex_local",
|
||||
supported: true,
|
||||
mode: "ephemeral",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCodexSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildCodexSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncCodexSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
_desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
return buildCodexSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveCodexDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { parseCodexJsonl } from "./parse.js";
|
||||
import { codexHomeDir, readCodexAuthInfo } from "./quota.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
const CODEX_AUTH_REQUIRED_RE =
|
||||
/(?:not\s+logged\s+in|login\s+required|authentication\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|api[_\s-]?key.*required|please\s+run\s+`?codex\s+login`?)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "codex");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "codex_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "codex_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "codex_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "codex_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
||||
const hostOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
|
||||
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "codex_openai_api_key_present",
|
||||
level: "info",
|
||||
message: "OPENAI_API_KEY is set for Codex authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
const codexHome = isNonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : undefined;
|
||||
const codexAuth = await readCodexAuthInfo(codexHome).catch(() => null);
|
||||
if (codexAuth) {
|
||||
checks.push({
|
||||
code: "codex_native_auth_present",
|
||||
level: "info",
|
||||
message: "Codex is authenticated via its own auth configuration.",
|
||||
detail: codexAuth.email ? `Logged in as ${codexAuth.email}.` : `Credentials found in ${path.join(codexHome ?? codexHomeDir(), "auth.json")}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "codex_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY is not set. Codex runs may fail until authentication is configured.",
|
||||
hint: "Set OPENAI_API_KEY in adapter env, shell environment, or run `codex auth` to log in.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "codex_cwd_invalid" && check.code !== "codex_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "codex")) {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `codex`.",
|
||||
detail: command,
|
||||
hint: "Use the `codex` CLI command to run the automatic login and installation probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, "").trim();
|
||||
const modelReasoningEffort = asString(
|
||||
config.modelReasoningEffort,
|
||||
asString(config.reasoningEffort, ""),
|
||||
).trim();
|
||||
const search = asBoolean(config.search, false);
|
||||
const bypass = asBoolean(
|
||||
config.dangerouslyBypassApprovalsAndSandbox,
|
||||
asBoolean(config.dangerouslyBypassSandbox, false),
|
||||
);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["exec", "--json"];
|
||||
if (search) args.unshift("--search");
|
||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (model) args.push("--model", model);
|
||||
if (modelReasoningEffort) {
|
||||
args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("-");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
const parsed = parseCodexJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Codex hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Codex can run `Respond with hello` from this directory manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Codex hello probe succeeded."
|
||||
: "Codex probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Codex CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Configure OPENAI_API_KEY in adapter env/shell or run `codex login`, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "codex_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Codex hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `codex exec --json -` manually in this working directory and prompt `Respond with hello` to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
|
||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.search = v.search;
|
||||
ac.dangerouslyBypassApprovalsAndSandbox =
|
||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
||||
? v.dangerouslyBypassSandbox
|
||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
if (v.workspaceStrategyType === "git_worktree") {
|
||||
ac.workspaceStrategy = {
|
||||
type: "git_worktree",
|
||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
||||
};
|
||||
}
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseCodexStdoutLine } from "./parse-stdout.js";
|
||||
export { buildCodexLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,259 @@
|
||||
import { type TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommandExecutionItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const id = asString(item.id);
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const safeCommand = command;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
|
||||
if (phase === "started") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
toolUseId: id || command || "command_execution",
|
||||
input: {
|
||||
id,
|
||||
command: safeCommand,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (safeCommand) lines.push(`command: ${safeCommand}`);
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(output);
|
||||
}
|
||||
|
||||
const isError =
|
||||
(exitCode !== null && exitCode !== 0) ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: id || command || "command_execution",
|
||||
content: lines.join("\n").trim() || "command completed",
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
function parseFileChangeItem(item: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const entries = changes
|
||||
.map((changeRaw) => asRecord(changeRaw))
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return [{ kind: "system", ts, text: "file changes applied" }];
|
||||
}
|
||||
|
||||
const preview = entries.slice(0, 6).join(", ");
|
||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
||||
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
||||
}
|
||||
|
||||
function parseCodexItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const itemType = asString(item.type);
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "assistant", ts, text }];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "thinking", ts, text }];
|
||||
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
||||
}
|
||||
|
||||
if (itemType === "command_execution") {
|
||||
return parseCommandExecutionItem(item, ts, phase);
|
||||
}
|
||||
|
||||
if (itemType === "file_change" && phase === "completed") {
|
||||
return parseFileChangeItem(item, ts);
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(item.name, "unknown"),
|
||||
toolUseId: asString(item.id),
|
||||
input: item.input ?? {},
|
||||
}];
|
||||
}
|
||||
|
||||
if (itemType === "tool_result" && phase === "completed") {
|
||||
const toolUseId = asString(item.tool_use_id, asString(item.id));
|
||||
const content =
|
||||
asString(item.content) ||
|
||||
asString(item.output) ||
|
||||
asString(item.result) ||
|
||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
return [{ kind: "tool_result", ts, toolUseId, content, isError }];
|
||||
}
|
||||
|
||||
if (itemType === "error" && phase === "completed") {
|
||||
const text = errorText(item.message ?? item.error ?? item);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`,
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = asString(parsed.thread_id);
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: asString(parsed.model, "codex"),
|
||||
sessionId: threadId,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "turn.started") {
|
||||
return [{ kind: "system", ts, text: "turn started" }];
|
||||
}
|
||||
|
||||
if (type === "item.started" || type === "item.completed") {
|
||||
const item = asRecord(parsed.item);
|
||||
if (!item) return [{ kind: "system", ts, text: type.replace(".", " ") }];
|
||||
return parseCodexItem(item, ts, type === "item.started" ? "started" : "completed");
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens);
|
||||
const outputTokens = asNumber(usage?.output_tokens);
|
||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype),
|
||||
isError: parsed.is_error === true,
|
||||
errors: Array.isArray(parsed.errors)
|
||||
? parsed.errors.map(errorText).filter(Boolean)
|
||||
: [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens);
|
||||
const outputTokens = asNumber(usage?.output_tokens);
|
||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype, "turn.failed"),
|
||||
isError: true,
|
||||
errors: message ? [message] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
return [{ kind: "stderr", ts, text: message || line }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/cursor-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import pc from "picocolors";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function printUserMessage(messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(pc.gray(`user: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(pc.gray(`user: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`user: ${text}`));
|
||||
}
|
||||
}
|
||||
|
||||
function printAssistantMessage(messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(pc.green(`assistant: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
const input = part.input ?? part.arguments ?? part.args;
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result") {
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printToolCallEventTopLevel(parsed: Record<string, unknown>): void {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "")));
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
if (!toolCall) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
const args = payload.args ?? asRecord(payload.function)?.arguments;
|
||||
const result =
|
||||
payload.result ??
|
||||
payload.output ??
|
||||
payload.error ??
|
||||
asRecord(payload.function)?.result ??
|
||||
asRecord(payload.function)?.output;
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
subtype === "failed" ||
|
||||
subtype === "error" ||
|
||||
subtype === "cancelled" ||
|
||||
payload.error !== undefined;
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${callId ? ` (${callId})` : ""}`));
|
||||
if (args !== undefined) {
|
||||
console.log(pc.gray(stringifyUnknown(args)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const header = `tool_result${isError ? " (error)" : ""}${callId ? ` (${callId})` : ""}`;
|
||||
console.log((isError ? pc.red : pc.cyan)(header));
|
||||
if (result !== undefined) {
|
||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(result)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||
}
|
||||
|
||||
function printLegacyToolEvent(part: Record<string, unknown>): void {
|
||||
const tool = asString(part.tool, "tool");
|
||||
const callId = asString(part.callID, asString(part.id, ""));
|
||||
const state = asRecord(part.state);
|
||||
const status = asString(state?.status);
|
||||
const input = state?.input;
|
||||
const output = asString(state?.output).replace(/\s+$/, "");
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const exit = asNumber(metadata?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exit) && exit !== 0);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
|
||||
if (input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(input)));
|
||||
}
|
||||
}
|
||||
|
||||
if (status || output) {
|
||||
const summary = [
|
||||
"tool_result",
|
||||
status ? `status=${status}` : "",
|
||||
Number.isFinite(exit) ? `exit=${exit}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
console.log((isError ? pc.red : pc.cyan)(summary));
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printCursorStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = normalizeCursorStreamLine(raw).line;
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID);
|
||||
const model = asString(parsed.model);
|
||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.log(pc.blue(`Cursor init${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
printAssistantMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
printUserMessage(parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
printToolCallEventTopLevel(parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||
const output = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||
const cached = asNumber(
|
||||
usage?.cached_input_tokens,
|
||||
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||
|
||||
console.log(pc.blue(`result: subtype=${subtype}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
const resultText = asString(parsed.result).trim();
|
||||
if (resultText) console.log((isError ? pc.red : pc.green)(`assistant: ${resultText}`));
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean) : [];
|
||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line;
|
||||
console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json event shapes.
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
if (part) {
|
||||
printLegacyToolEvent(part);
|
||||
} else {
|
||||
console.log(pc.yellow("tool_use"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step_finish");
|
||||
const input = asNumber(tokens?.input);
|
||||
const output = asNumber(tokens?.output);
|
||||
const cached = asNumber(cache?.read);
|
||||
const cost = asNumber(part?.cost);
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printCursorStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,83 @@
|
||||
export const type = "cursor";
|
||||
export const label = "Cursor CLI (local)";
|
||||
export const DEFAULT_CURSOR_LOCAL_MODEL = "auto";
|
||||
|
||||
const CURSOR_FALLBACK_MODEL_IDS = [
|
||||
"auto",
|
||||
"composer-1.5",
|
||||
"composer-1",
|
||||
"gpt-5.3-codex-low",
|
||||
"gpt-5.3-codex-low-fast",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.3-codex-fast",
|
||||
"gpt-5.3-codex-high",
|
||||
"gpt-5.3-codex-high-fast",
|
||||
"gpt-5.3-codex-xhigh",
|
||||
"gpt-5.3-codex-xhigh-fast",
|
||||
"gpt-5.3-codex-spark-preview",
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex-low",
|
||||
"gpt-5.2-codex-low-fast",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.2-codex-fast",
|
||||
"gpt-5.2-codex-high",
|
||||
"gpt-5.2-codex-high-fast",
|
||||
"gpt-5.2-codex-xhigh",
|
||||
"gpt-5.2-codex-xhigh-fast",
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-max-high",
|
||||
"gpt-5.2-high",
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-codex-mini",
|
||||
"opus-4.6-thinking",
|
||||
"opus-4.6",
|
||||
"opus-4.5",
|
||||
"opus-4.5-thinking",
|
||||
"sonnet-4.6",
|
||||
"sonnet-4.6-thinking",
|
||||
"sonnet-4.5",
|
||||
"sonnet-4.5-thinking",
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
"grok",
|
||||
"kimi-k2.5",
|
||||
];
|
||||
|
||||
export const models = CURSOR_FALLBACK_MODEL_IDS.map((id) => ({ id, label: id }));
|
||||
|
||||
export const agentConfigurationDoc = `# cursor agent configuration
|
||||
|
||||
Adapter: cursor
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run Cursor Agent CLI locally as the agent runtime
|
||||
- You want Cursor chat session resume across heartbeats via --resume
|
||||
- You want structured stream output in run logs via --output-format stream-json
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- Cursor Agent CLI is not installed on the machine
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Cursor model id (for example auto or gpt-5.3-codex)
|
||||
- mode (string, optional): Cursor execution mode passed as --mode (plan|ask). Leave unset for normal autonomous runs.
|
||||
- command (string, optional): defaults to "agent"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs are executed with: agent -p --output-format stream-json ...
|
||||
- Prompts are piped to Cursor via stdin.
|
||||
- Sessions are resumed with --resume when stored session cwd matches current cwd.
|
||||
- Paperclip auto-injects local skills into "~/.cursor/skills" when missing, so Cursor can discover "$paperclip" and related skills on local runs.
|
||||
- Paperclip auto-adds --yolo unless one of --trust/--yolo/-f is already present in extraArgs.
|
||||
`;
|
||||
@@ -0,0 +1,528 @@
|
||||
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 {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
renderTemplate,
|
||||
joinPromptSections,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCursorBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "CURSOR_API_KEY") || hasNonEmptyEnvValue(env, "OPENAI_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function resolveCursorBiller(
|
||||
env: Record<string, string>,
|
||||
billingType: "api" | "subscription",
|
||||
provider: string | null,
|
||||
): string {
|
||||
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, null);
|
||||
if (openAiCompatibleBiller === "openrouter") return "openrouter";
|
||||
if (billingType === "subscription") return "cursor";
|
||||
return provider ?? "cursor";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
const trimmed = model.trim().toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash > 0) return trimmed.slice(0, slash);
|
||||
if (trimmed.includes("sonnet") || trimmed.includes("claude")) return "anthropic";
|
||||
if (trimmed.startsWith("gpt") || trimmed.startsWith("o")) return "openai";
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeMode(rawMode: string): "plan" | "ask" | null {
|
||||
const mode = rawMode.trim().toLowerCase();
|
||||
if (mode === "plan" || mode === "ask") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const paperclipKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (paperclipKeys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function cursorSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".cursor", "skills");
|
||||
}
|
||||
|
||||
type EnsureCursorSkillsInjectedOptions = {
|
||||
skillsDir?: string | null;
|
||||
skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>;
|
||||
skillsHome?: string;
|
||||
linkSkill?: (source: string, target: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function ensureCursorSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
options: EnsureCursorSkillsInjectedOptions = {},
|
||||
) {
|
||||
const skillsEntries = options.skillsEntries
|
||||
?? (options.skillsDir
|
||||
? (await fs.readdir(options.skillsDir, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
key: entry.name,
|
||||
runtimeName: entry.name,
|
||||
source: path.join(options.skillsDir!, entry.name),
|
||||
}))
|
||||
: await readPaperclipRuntimeSkillEntries({}, __moduleDir));
|
||||
if (skillsEntries.length === 0) return;
|
||||
|
||||
const skillsHome = options.skillsHome ?? cursorSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to prepare Cursor skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||
skillsHome,
|
||||
skillsEntries.map((entry) => entry.runtimeName),
|
||||
);
|
||||
for (const skillName of removedSkills) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`,
|
||||
);
|
||||
}
|
||||
const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target));
|
||||
for (const entry of skillsEntries) {
|
||||
const target = path.join(skillsHome, entry.runtimeName);
|
||||
try {
|
||||
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
||||
if (result === "skipped") continue;
|
||||
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.key}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to inject Cursor skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "agent");
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
const mode = normalizeMode(asString(config.mode, ""));
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
|
||||
await ensureCursorSkillsInjected(onLog, {
|
||||
skillsEntries: cursorSkillEntries.filter((entry) => desiredCursorSkillNames.includes(entry.key)),
|
||||
});
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
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)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveCursorBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Cursor session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
let instructionsChars = 0;
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
instructionsChars = instructionsPrefix.length;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = [];
|
||||
if (autoTrustEnabled) {
|
||||
notes.push("Auto-added --yolo to bypass interactive prompts.");
|
||||
}
|
||||
notes.push("Prompt is piped to Cursor via stdin.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["-p", "--output-format", "stream-json", "--workspace", cwd];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model) args.push("--model", model);
|
||||
if (mode) args.push("--mode", mode);
|
||||
if (autoTrustEnabled) args.push("--yolo");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "cursor",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args,
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
let stdoutLineBuffer = "";
|
||||
const emitNormalizedStdoutLine = async (rawLine: string) => {
|
||||
const normalized = normalizeCursorStreamLine(rawLine);
|
||||
if (!normalized.line) return;
|
||||
await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`);
|
||||
};
|
||||
const flushStdoutChunk = async (chunk: string, finalize = false) => {
|
||||
const combined = `${stdoutLineBuffer}${chunk}`;
|
||||
const lines = combined.split(/\r?\n/);
|
||||
stdoutLineBuffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
await emitNormalizedStdoutLine(line);
|
||||
}
|
||||
|
||||
if (finalize) {
|
||||
const trailing = stdoutLineBuffer.trim();
|
||||
stdoutLineBuffer = "";
|
||||
if (trailing) {
|
||||
await emitNormalizedStdoutLine(trailing);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
stdin: prompt,
|
||||
onSpawn,
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream !== "stdout") {
|
||||
await onLog(stream, chunk);
|
||||
return;
|
||||
}
|
||||
await flushStdoutChunk(chunk);
|
||||
},
|
||||
});
|
||||
await flushStdoutChunk("", true);
|
||||
|
||||
return {
|
||||
proc,
|
||||
parsed: parseCursorJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const providerFromModel = resolveProviderFromModel(model);
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseCursorJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`Cursor exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: providerFromModel,
|
||||
biller: resolveCursorBiller(effectiveEnv, billingType, providerFromModel),
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isCursorUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Cursor resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export { execute, ensureCursorSkillsInjected } from "./execute.js";
|
||||
export { listCursorSkills, syncCursorSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message =
|
||||
asString(rec.message, "") ||
|
||||
asString(rec.error, "") ||
|
||||
asString(rec.code, "") ||
|
||||
asString(rec.detail, "");
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssistantText(message: unknown): string[] {
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
const rec = parseObject(message);
|
||||
const direct = asString(rec.text, "").trim();
|
||||
const lines: string[] = direct ? [direct] : [];
|
||||
const content = Array.isArray(rec.content) ? rec.content : [];
|
||||
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
const type = asString(part.type, "").trim();
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function readSessionId(event: Record<string, unknown>): string | null {
|
||||
return (
|
||||
asString(event.session_id, "").trim() ||
|
||||
asString(event.sessionId, "").trim() ||
|
||||
asString(event.sessionID, "").trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCursorJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let totalCostUsd = 0;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = normalizeCursorStreamLine(rawLine).line;
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSession = readSessionId(event);
|
||||
if (foundSession) sessionId = foundSession;
|
||||
|
||||
const type = asString(event.type, "").trim();
|
||||
|
||||
if (type === "assistant") {
|
||||
messages.push(...collectAssistantText(event.message));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usageObj = parseObject(event.usage);
|
||||
usage.inputTokens += asNumber(
|
||||
usageObj.input_tokens,
|
||||
asNumber(usageObj.inputTokens, 0),
|
||||
);
|
||||
usage.cachedInputTokens += asNumber(
|
||||
usageObj.cached_input_tokens,
|
||||
asNumber(usageObj.cachedInputTokens, asNumber(usageObj.cache_read_input_tokens, 0)),
|
||||
);
|
||||
usage.outputTokens += asNumber(
|
||||
usageObj.output_tokens,
|
||||
asNumber(usageObj.outputTokens, 0),
|
||||
);
|
||||
totalCostUsd += asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, 0)));
|
||||
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
const resultText = asString(event.result, "").trim();
|
||||
if (resultText && messages.length === 0) {
|
||||
messages.push(resultText);
|
||||
}
|
||||
if (isError) {
|
||||
const resultError = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (resultError) errorMessage = resultError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
|
||||
if (message) errorMessage = message;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error") {
|
||||
const message = asErrorText(event.message ?? event.error ?? event.detail).trim();
|
||||
if (message) errorMessage = message;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json shapes.
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = parseObject(event.part);
|
||||
const tokens = parseObject(part.tokens);
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0);
|
||||
totalCostUsd += asNumber(part.cost, 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCursorUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+(session|chat)|session\s+.*\s+not\s+found|chat\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|could\s+not\s+resume/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildPersistentSkillSnapshot,
|
||||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveCursorSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".cursor", "skills");
|
||||
}
|
||||
|
||||
async function buildCursorSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveCursorSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
adapterType: "cursor",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
installed,
|
||||
skillsHome,
|
||||
locationLabel: "~/.cursor/skills",
|
||||
missingDetail: "Configured but not currently linked into the Cursor skills home.",
|
||||
externalConflictDetail: "Skill name is occupied by an external installation.",
|
||||
externalDetail: "Installed outside Paperclip management.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCursorSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildCursorSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncCursorSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const skillsHome = resolveCursorSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.key)) continue;
|
||||
const target = path.join(skillsHome, available.runtimeName);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByRuntimeName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(available.key)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildCursorSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveCursorDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
import { parseCursorJsonl } from "./parse.js";
|
||||
import { hasCursorTrustBypassArg } from "../shared/trust.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
const CURSOR_AUTH_REQUIRED_RE =
|
||||
/(?:authentication\s+required|not\s+authenticated|not\s+logged\s+in|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|cursor[_\s-]?api[_\s-]?key|run\s+'?agent\s+login'?\s+first|api(?:[_\s-]?key)?(?:\s+is)?\s+required)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "agent");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "cursor_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "cursor_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "cursor_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "cursor_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configCursorApiKey = env.CURSOR_API_KEY;
|
||||
const hostCursorApiKey = process.env.CURSOR_API_KEY;
|
||||
if (isNonEmpty(configCursorApiKey) || isNonEmpty(hostCursorApiKey)) {
|
||||
const source = isNonEmpty(configCursorApiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "cursor_api_key_present",
|
||||
level: "info",
|
||||
message: "CURSOR_API_KEY is set for Cursor authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "cursor_api_key_missing",
|
||||
level: "warn",
|
||||
message: "CURSOR_API_KEY is not set. Cursor runs may fail until authentication is configured.",
|
||||
hint: "Set CURSOR_API_KEY in adapter env or run `agent login`.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "cursor_cwd_invalid" && check.code !== "cursor_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "agent")) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `agent`.",
|
||||
detail: command,
|
||||
hint: "Use the `agent` CLI command to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_CURSOR_LOCAL_MODEL).trim();
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const autoTrustEnabled = !hasCursorTrustBypassArg(extraArgs);
|
||||
const args = ["-p", "--mode", "ask", "--output-format", "json", "--workspace", cwd];
|
||||
if (model) args.push("--model", model);
|
||||
if (autoTrustEnabled) args.push("--yolo");
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`cursor-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
const parsed = parseCursorJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Cursor hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify `agent -p --mode ask --output-format json \"Respond with hello.\"` manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "cursor_hello_probe_passed" : "cursor_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Cursor hello probe succeeded."
|
||||
: "Cursor probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `agent -p --mode ask --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (CURSOR_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Cursor CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `agent login` or configure CURSOR_API_KEY in adapter env/shell, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "cursor_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Cursor hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `agent -p --mode ask --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function normalizeCursorStreamLine(rawLine: string): {
|
||||
stream: "stdout" | "stderr" | null;
|
||||
line: string;
|
||||
} {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) return { stream: null, line: "" };
|
||||
|
||||
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*([\[{].*)$/i);
|
||||
if (!prefixed) {
|
||||
return { stream: null, line: trimmed };
|
||||
}
|
||||
|
||||
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
|
||||
const line = (prefixed[2] ?? "").trim();
|
||||
return { stream, line };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function hasCursorTrustBypassArg(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) =>
|
||||
arg === "--trust" ||
|
||||
arg === "--yolo" ||
|
||||
arg === "-f" ||
|
||||
arg.startsWith("--trust="),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function normalizeMode(value: string): "plan" | "ask" | null {
|
||||
const mode = value.trim().toLowerCase();
|
||||
if (mode === "plan" || mode === "ask") return mode;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
const mode = normalizeMode(v.thinkingEffort);
|
||||
if (mode) ac.mode = mode;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseCursorStdoutLine } from "./parse-stdout.js";
|
||||
export { buildCursorLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { normalizeCursorStreamLine } from "../shared/stream.js";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Max chars of stdout/stderr to show in run log for shell tool results. */
|
||||
const SHELL_OUTPUT_TRUNCATE = 2000;
|
||||
|
||||
/**
|
||||
* Format shell tool result for run log: exit code + stdout/stderr (truncated).
|
||||
* If the result is not a shell-shaped object, returns full stringify.
|
||||
*/
|
||||
function formatShellToolResultForLog(result: unknown): string {
|
||||
const obj = asRecord(result);
|
||||
if (!obj) return stringifyUnknown(result);
|
||||
const success = asRecord(obj.success);
|
||||
if (!success) return stringifyUnknown(result);
|
||||
const exitCode = asNumber(success.exitCode, NaN);
|
||||
const stdout = asString(success.stdout).trim();
|
||||
const stderr = asString(success.stderr).trim();
|
||||
const hasShellShape = Number.isFinite(exitCode) || stdout.length > 0 || stderr.length > 0;
|
||||
if (!hasShellShape) return stringifyUnknown(result);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (Number.isFinite(exitCode)) lines.push(`exit ${exitCode}`);
|
||||
if (stdout) {
|
||||
const out = stdout.length > SHELL_OUTPUT_TRUNCATE ? stdout.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stdout;
|
||||
lines.push("<stdout>");
|
||||
lines.push(out);
|
||||
}
|
||||
if (stderr) {
|
||||
const err = stderr.length > SHELL_OUTPUT_TRUNCATE ? stderr.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stderr;
|
||||
lines.push("<stderr>");
|
||||
lines.push(err);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Return compact input for run log when tool is shell/shellToolCall (command only). */
|
||||
function compactShellToolInput(rawInput: unknown, payload?: Record<string, unknown>): unknown {
|
||||
const cmd = asString(payload?.command ?? asRecord(rawInput)?.command);
|
||||
if (cmd) return { command: cmd };
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
function parseUserMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "user", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind: "user", ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text") continue;
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "user", ts, text });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) {
|
||||
entries.push({ kind: "assistant", ts, text: directText });
|
||||
}
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
const rawInput = part.input ?? part.arguments ?? part.args ?? {};
|
||||
const input =
|
||||
name === "shellToolCall" || name === "shell"
|
||||
? compactShellToolInput(rawInput, asRecord(rawInput) ?? undefined)
|
||||
: rawInput;
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
toolUseId:
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
undefined,
|
||||
input,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result") {
|
||||
const toolUseId =
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
"tool_result";
|
||||
const rawOutput = part.output ?? part.result ?? part.text;
|
||||
const contentText =
|
||||
typeof rawOutput === "object" && rawOutput !== null
|
||||
? formatShellToolResultForLog(rawOutput)
|
||||
: asString(rawOutput) || stringifyUnknown(rawOutput);
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: contentText,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const subtype = asString(event.subtype).trim().toLowerCase();
|
||||
const callId =
|
||||
asString(event.call_id) ||
|
||||
asString(event.callId) ||
|
||||
asString(event.id) ||
|
||||
"tool_call";
|
||||
const toolCall = asRecord(event.tool_call ?? event.toolCall);
|
||||
if (!toolCall) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
const rawInput = payload.args ?? asRecord(payload.function)?.arguments ?? payload;
|
||||
const isShellTool = toolName === "shellToolCall" || toolName === "shell";
|
||||
const input = isShellTool ? compactShellToolInput(rawInput, payload) : rawInput;
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
toolUseId: callId,
|
||||
input,
|
||||
}];
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const result =
|
||||
payload.result ??
|
||||
payload.output ??
|
||||
payload.error ??
|
||||
asRecord(payload.function)?.result ??
|
||||
asRecord(payload.function)?.output;
|
||||
const isError =
|
||||
event.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
asString(payload.status).toLowerCase() === "error" ||
|
||||
asString(payload.status).toLowerCase() === "failed" ||
|
||||
asString(payload.status).toLowerCase() === "cancelled" ||
|
||||
payload.error !== undefined;
|
||||
const content =
|
||||
result !== undefined
|
||||
? isShellTool
|
||||
? formatShellToolResultForLog(result)
|
||||
: stringifyUnknown(result)
|
||||
: `${toolName} completed`;
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: callId,
|
||||
content,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}`,
|
||||
}];
|
||||
}
|
||||
|
||||
export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const normalized = normalizeCursorStreamLine(line);
|
||||
if (!normalized.line) return [];
|
||||
|
||||
const parsed = asRecord(safeJsonParse(normalized.line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID);
|
||||
return [{ kind: "init", ts, model: asString(parsed.model, "cursor"), sessionId }];
|
||||
}
|
||||
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system" }];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const entries = parseAssistantMessage(parsed.message, ts);
|
||||
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
return parseUserMessage(parsed.message, ts);
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const textFromTopLevel = asString(parsed.text);
|
||||
const textFromDelta = asString(asRecord(parsed.delta)?.text);
|
||||
const text = textFromTopLevel.length > 0 ? textFromTopLevel : textFromDelta;
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const isDelta = subtype === "delta" || asRecord(parsed.delta) !== null;
|
||||
if (!text.trim()) return [];
|
||||
return [{ kind: "thinking", ts, text: isDelta ? text : text.trim(), ...(isDelta ? { delta: true } : {}) }];
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
return parseCursorToolCallEvent(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||
const outputTokens = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
|
||||
const cachedTokens = asNumber(
|
||||
usage?.cached_input_tokens,
|
||||
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
|
||||
);
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const errors = Array.isArray(parsed.errors)
|
||||
? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean)
|
||||
: [];
|
||||
const errorText = asString(parsed.error).trim();
|
||||
if (errorText) errors.push(errorText);
|
||||
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
|
||||
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype,
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || normalized.line;
|
||||
return [{ kind: "stderr", ts, text: message }];
|
||||
}
|
||||
|
||||
// Compatibility with older stream-json event shapes.
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
return [{ kind: "system", ts, text: `step started${sessionId ? ` (${sessionId})` : ""}` }];
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
|
||||
const toolName = asString(part?.tool, "tool");
|
||||
const state = asRecord(part?.state);
|
||||
const input = state?.input ?? {};
|
||||
const output = asString(state?.output).trim();
|
||||
const status = asString(state?.status).trim();
|
||||
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
|
||||
const isError =
|
||||
status === "failed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled" ||
|
||||
(Number.isFinite(exitCode) && exitCode !== 0);
|
||||
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input,
|
||||
},
|
||||
];
|
||||
|
||||
if (status || output) {
|
||||
const lines: string[] = [];
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(output);
|
||||
}
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: lines.join("\n").trim() || "tool completed",
|
||||
isError,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason);
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: reason,
|
||||
inputTokens: asNumber(tokens?.input),
|
||||
outputTokens: asNumber(tokens?.output),
|
||||
cachedTokens: asNumber(cache?.read),
|
||||
costUsd: asNumber(part?.cost),
|
||||
subtype: reason || "step_finish",
|
||||
isError: reason === "error" || reason === "failed",
|
||||
errors: [],
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-gemini-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/gemini-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return;
|
||||
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) console.log(colorize(`${prefix}: ${directText}`));
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
const input = part.input ?? part.arguments ?? part.args;
|
||||
if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool_response") {
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
|
||||
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
|
||||
const cached = asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
);
|
||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
}
|
||||
|
||||
export function printGeminiStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId =
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID) ||
|
||||
asString(parsed.checkpoint_id);
|
||||
const model = asString(parsed.model);
|
||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
if (subtype === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
printTextMessage("assistant", pc.green, parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
printTextMessage("user", pc.gray, parsed.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
const [toolName] = toolCall ? Object.keys(toolCall) : [];
|
||||
if (!toolCall || !toolName) {
|
||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||
return;
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
console.log(pc.yellow(`tool_call: ${toolName}`));
|
||||
console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
|
||||
return;
|
||||
}
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
payload.error !== undefined ||
|
||||
asString(payload.status).toLowerCase() === "error";
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
|
||||
return;
|
||||
}
|
||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
printUsage(parsed);
|
||||
const subtype = asString(parsed.subtype, "result");
|
||||
const isError = parsed.is_error === true;
|
||||
if (subtype || isError) {
|
||||
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
if (text) console.log(pc.red(`error: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printGeminiStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
export const type = "gemini_local";
|
||||
export const label = "Gemini CLI (local)";
|
||||
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
||||
|
||||
export const models = [
|
||||
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
|
||||
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
||||
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
||||
];
|
||||
|
||||
export const agentConfigurationDoc = `# gemini_local agent configuration
|
||||
|
||||
Adapter: gemini_local
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run the Gemini CLI locally on the host machine
|
||||
- You want Gemini chat sessions resumed across heartbeats with --resume
|
||||
- You want Paperclip skills injected locally without polluting the global environment
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use http or openclaw_gateway)
|
||||
- You only need a one-shot script without an AI coding agent loop (use process)
|
||||
- Gemini CLI is not installed on the machine that runs Paperclip
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- model (string, optional): Gemini model id. Defaults to auto.
|
||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
||||
- command (string, optional): defaults to "gemini"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- Runs use positional prompt arguments, not stdin.
|
||||
- Sessions resume with --resume when stored session cwd matches the current cwd.
|
||||
- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
|
||||
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
|
||||
`;
|
||||
@@ -0,0 +1,461 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
buildPaperclipEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
joinPromptSections,
|
||||
ensurePathInEnv,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
parseObject,
|
||||
redactEnvForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import {
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
isGeminiTurnLimitResult,
|
||||
isGeminiUnknownSessionError,
|
||||
parseGeminiJsonl,
|
||||
} from "./parse.js";
|
||||
import { firstNonEmptyLine } from "./utils.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function renderPaperclipEnvNote(env: Record<string, string>): string {
|
||||
const paperclipKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("PAPERCLIP_"))
|
||||
.sort();
|
||||
if (paperclipKeys.length === 0) return "";
|
||||
return [
|
||||
"Paperclip runtime note:",
|
||||
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
|
||||
return [
|
||||
"Paperclip API access note:",
|
||||
"Use run_shell_command with curl to make Paperclip API requests.",
|
||||
"GET example:",
|
||||
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`,
|
||||
"POST/PATCH example:",
|
||||
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function geminiSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".gemini", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks.
|
||||
* This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds
|
||||
* both its auth credentials and the injected skills in the real home directory.
|
||||
*/
|
||||
async function ensureGeminiSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||
desiredSkillNames?: string[],
|
||||
): Promise<void> {
|
||||
const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key));
|
||||
const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key));
|
||||
if (selectedEntries.length === 0) return;
|
||||
|
||||
const skillsHome = geminiSkillsHome();
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
||||
skillsHome,
|
||||
selectedEntries.map((entry) => entry.runtimeName),
|
||||
);
|
||||
for (const skillName of removedSkills) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Removed maintainer-only Gemini 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" : "Linked"} Gemini skill: ${entry.key}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to link Gemini skill "${entry.key}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "gemini");
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
|
||||
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();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
|
||||
await ensureGeminiSkillsInjected(onLog, geminiSkillEntries, desiredGeminiSkillNames);
|
||||
|
||||
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)
|
||||
: [];
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const effectiveEnv = Object.fromEntries(
|
||||
Object.entries({ ...process.env, ...env }).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
const billingType = resolveGeminiBillingType(effectiveEnv);
|
||||
const runtimeEnv = ensurePathInEnv(effectiveEnv);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."];
|
||||
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
||||
const templateData = {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
};
|
||||
const renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = renderPaperclipEnvNote(env);
|
||||
const apiAccessNote = renderApiAccessNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
paperclipEnvNote,
|
||||
apiAccessNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["--output-format", "stream-json"];
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
args.push("--approval-mode", "yolo");
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
args.push("--sandbox=none");
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("--prompt", prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "gemini_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, index) => (
|
||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
||||
)),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
proc,
|
||||
parsed: parseGeminiJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseGeminiJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
isRetry = false,
|
||||
): AdapterExecutionResult => {
|
||||
const authMeta = detectGeminiAuthRequired({
|
||||
parsed: attempt.parsed.resultEvent,
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
});
|
||||
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
||||
|
||||
// On retry, don't fall back to old session ID — the old session was stale
|
||||
const canFallbackToRuntimeSession = !isRetry;
|
||||
const resolvedSessionId = attempt.parsed.sessionId
|
||||
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const structuredFailure = attempt.parsed.resultEvent
|
||||
? describeGeminiFailure(attempt.parsed.resultEvent)
|
||||
: null;
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
structuredFailure ||
|
||||
stderrLine ||
|
||||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
||||
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: "google",
|
||||
biller: "google",
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: attempt.parsed.resultEvent ?? {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
question: attempt.parsed.question,
|
||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { listGeminiSkills, syncGeminiSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
parseGeminiJsonl,
|
||||
isGeminiUnknownSessionError,
|
||||
describeGeminiFailure,
|
||||
detectGeminiAuthRequired,
|
||||
isGeminiTurnLimitResult,
|
||||
} from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function collectMessageText(message: unknown): string[] {
|
||||
if (typeof message === "string") {
|
||||
const trimmed = message.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
const record = parseObject(message);
|
||||
const direct = asString(record.text, "").trim();
|
||||
const lines: string[] = direct ? [direct] : [];
|
||||
const content = Array.isArray(record.content) ? record.content : [];
|
||||
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
const type = asString(part.type, "").trim();
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function readSessionId(event: Record<string, unknown>): string | null {
|
||||
return (
|
||||
asString(event.session_id, "").trim() ||
|
||||
asString(event.sessionId, "").trim() ||
|
||||
asString(event.sessionID, "").trim() ||
|
||||
asString(event.checkpoint_id, "").trim() ||
|
||||
asString(event.thread_id, "").trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message =
|
||||
asString(rec.message, "") ||
|
||||
asString(rec.error, "") ||
|
||||
asString(rec.code, "") ||
|
||||
asString(rec.detail, "");
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function accumulateUsage(
|
||||
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
|
||||
usageRaw: unknown,
|
||||
) {
|
||||
const usage = parseObject(usageRaw);
|
||||
const usageMetadata = parseObject(usage.usageMetadata);
|
||||
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
|
||||
|
||||
target.inputTokens += asNumber(
|
||||
source.input_tokens,
|
||||
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
|
||||
);
|
||||
target.cachedInputTokens += asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
||||
);
|
||||
target.outputTokens += asNumber(
|
||||
source.output_tokens,
|
||||
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseGeminiJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let costUsd: number | null = null;
|
||||
let resultEvent: Record<string, unknown> | null = null;
|
||||
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSessionId = readSessionId(event);
|
||||
if (foundSessionId) sessionId = foundSessionId;
|
||||
|
||||
const type = asString(event.type, "").trim();
|
||||
|
||||
if (type === "assistant") {
|
||||
messages.push(...collectMessageText(event.message));
|
||||
const messageObj = parseObject(event.message);
|
||||
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = parseObject(partRaw);
|
||||
if (asString(part.type, "").trim() === "question") {
|
||||
question = {
|
||||
prompt: asString(part.prompt, "").trim(),
|
||||
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
||||
const choice = parseObject(choiceRaw);
|
||||
return {
|
||||
key: asString(choice.key, "").trim(),
|
||||
label: asString(choice.label, "").trim(),
|
||||
description: asString(choice.description, "").trim() || undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
break; // only one question per message
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
resultEvent = event;
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
const resultText =
|
||||
asString(event.result, "").trim() ||
|
||||
asString(event.text, "").trim() ||
|
||||
asString(event.response, "").trim();
|
||||
if (resultText && messages.length === 0) messages.push(resultText);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
if (isError) {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||
if (text) errorMessage = text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
||||
if (subtype === "error") {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
||||
if (text) errorMessage = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish" || event.usage || event.usageMetadata) {
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd,
|
||||
errorMessage,
|
||||
resultEvent,
|
||||
question,
|
||||
};
|
||||
}
|
||||
|
||||
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||
const messages: string[] = [];
|
||||
const errorMsg = asString(parsed.error, "").trim();
|
||||
if (errorMsg) messages.push(errorMsg);
|
||||
|
||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry === "string") {
|
||||
const msg = entry.trim();
|
||||
if (msg) messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||
const obj = entry as Record<string, unknown>;
|
||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||
if (msg) {
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
messages.push(JSON.stringify(obj));
|
||||
} catch {
|
||||
// skip non-serializable entry
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
|
||||
const status = asString(parsed.status, "");
|
||||
const errors = extractGeminiErrorMessages(parsed);
|
||||
|
||||
const detail = errors[0] ?? "";
|
||||
const parts = ["Gemini run failed"];
|
||||
if (status) parts.push(`status=${status}`);
|
||||
if (detail) parts.push(detail);
|
||||
return parts.length > 1 ? parts.join(": ") : null;
|
||||
}
|
||||
|
||||
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
|
||||
const GEMINI_QUOTA_EXHAUSTED_RE =
|
||||
/(?:resource_exhausted|quota|rate[-\s]?limit|too many requests|\b429\b|billing details)/i;
|
||||
|
||||
export function detectGeminiAuthRequired(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { requiresAuth: boolean } {
|
||||
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
||||
const messages = [...errors, input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
|
||||
return { requiresAuth };
|
||||
}
|
||||
|
||||
export function detectGeminiQuotaExhausted(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { exhausted: boolean } {
|
||||
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
||||
const messages = [...errors, input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const exhausted = messages.some((line) => GEMINI_QUOTA_EXHAUSTED_RE.test(line));
|
||||
return { exhausted };
|
||||
}
|
||||
|
||||
export function isGeminiTurnLimitResult(
|
||||
parsed: Record<string, unknown> | null | undefined,
|
||||
exitCode?: number | null,
|
||||
): boolean {
|
||||
if (exitCode === 53) return true;
|
||||
if (!parsed) return false;
|
||||
|
||||
const status = asString(parsed.status, "").trim().toLowerCase();
|
||||
if (status === "turn_limit" || status === "max_turns") return true;
|
||||
|
||||
const error = asString(parsed.error, "").trim();
|
||||
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildPersistentSkillSnapshot,
|
||||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveGeminiSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".gemini", "skills");
|
||||
}
|
||||
|
||||
async function buildGeminiSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveGeminiSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
adapterType: "gemini_local",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
installed,
|
||||
skillsHome,
|
||||
locationLabel: "~/.gemini/skills",
|
||||
missingDetail: "Configured but not currently linked into the Gemini skills home.",
|
||||
externalConflictDetail: "Skill name is occupied by an external installation.",
|
||||
externalDetail: "Installed outside Paperclip management.",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listGeminiSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildGeminiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncGeminiSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const skillsHome = resolveGeminiSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.key)) continue;
|
||||
const target = path.join(skillsHome, available.runtimeName);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByRuntimeName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(available.key)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildGeminiSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveGeminiDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
asStringArray,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
parseObject,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import { detectGeminiAuthRequired, detectGeminiQuotaExhausted, parseGeminiJsonl } from "./parse.js";
|
||||
import { firstNonEmptyLine } from "./utils.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "gemini");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "gemini_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "gemini_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "gemini_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "gemini_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configGeminiApiKey = env.GEMINI_API_KEY;
|
||||
const hostGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
||||
const hostGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true";
|
||||
if (
|
||||
isNonEmpty(configGeminiApiKey) ||
|
||||
isNonEmpty(hostGeminiApiKey) ||
|
||||
isNonEmpty(configGoogleApiKey) ||
|
||||
isNonEmpty(hostGoogleApiKey) ||
|
||||
hasGca
|
||||
) {
|
||||
const source = hasGca
|
||||
? "Google account login (GCA)"
|
||||
: isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey)
|
||||
? "adapter config env"
|
||||
: "server environment";
|
||||
checks.push({
|
||||
code: "gemini_api_key_present",
|
||||
level: "info",
|
||||
message: "Gemini API credentials are set for CLI authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "gemini_api_key_missing",
|
||||
level: "info",
|
||||
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
|
||||
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "gemini")) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `gemini`.",
|
||||
detail: command,
|
||||
hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
||||
const sandbox = asBoolean(config.sandbox, false);
|
||||
const helloProbeTimeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 10));
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["--output-format", "stream-json", "--prompt", "Respond with hello."];
|
||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
||||
if (sandbox) {
|
||||
args.push("--sandbox");
|
||||
} else {
|
||||
args.push("--sandbox=none");
|
||||
}
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: helloProbeTimeoutSec,
|
||||
graceSec: 5,
|
||||
onLog: async () => { },
|
||||
},
|
||||
);
|
||||
const parsed = parseGeminiJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authMeta = detectGeminiAuthRequired({
|
||||
parsed: parsed.resultEvent,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
const quotaMeta = detectGeminiQuotaExhausted({
|
||||
parsed: parsed.resultEvent,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
|
||||
if (quotaMeta.exhausted) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_quota_exhausted",
|
||||
level: "warn",
|
||||
message: probe.timedOut
|
||||
? "Gemini CLI is retrying after quota exhaustion."
|
||||
: "Gemini CLI authentication is configured, but the current account or API key is over quota.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "The configured Gemini account or API key is over quota. Check ai.google.dev usage/billing, then retry the probe.",
|
||||
});
|
||||
} else if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "Gemini hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Gemini hello probe succeeded."
|
||||
: "Gemini probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (authMeta.requiresAuth) {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Gemini CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "gemini_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Gemini hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
||||
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseGeminiStdoutLine } from "./parse-stdout.js";
|
||||
export { buildGeminiLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,274 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind, ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind, ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
if (type !== "output_text" && type !== "text" && type !== "content") continue;
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) entries.push({ kind, ts, text });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
return text ? [{ kind: "assistant", ts, text }] : [];
|
||||
}
|
||||
|
||||
const message = asRecord(messageRaw);
|
||||
if (!message) return [];
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const directText = asString(message.text).trim();
|
||||
if (directText) entries.push({ kind: "assistant", ts, text: directText });
|
||||
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
for (const partRaw of content) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
const type = asString(part.type).trim();
|
||||
|
||||
if (type === "output_text" || type === "text" || type === "content") {
|
||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(part.text).trim();
|
||||
if (text) entries.push({ kind: "thinking", ts, text });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
const name = asString(part.name, asString(part.tool, "tool"));
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name,
|
||||
input: part.input ?? part.arguments ?? part.args ?? {},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_result" || type === "tool_response") {
|
||||
const toolUseId =
|
||||
asString(part.tool_use_id) ||
|
||||
asString(part.toolUseId) ||
|
||||
asString(part.call_id) ||
|
||||
asString(part.id) ||
|
||||
"tool_result";
|
||||
const contentText =
|
||||
asString(part.output) ||
|
||||
asString(part.text) ||
|
||||
asString(part.result) ||
|
||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
||||
entries.push({
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId,
|
||||
content: contentText,
|
||||
isError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseTopLevelToolEvent(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
|
||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||
if (!toolCall) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
|
||||
const [toolName] = Object.keys(toolCall);
|
||||
if (!toolName) {
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||
}
|
||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||
|
||||
if (subtype === "started" || subtype === "start") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
input: payload.args ?? payload.input ?? payload.arguments ?? payload,
|
||||
}];
|
||||
}
|
||||
|
||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||
const result = payload.result ?? payload.output ?? payload.error;
|
||||
const isError =
|
||||
parsed.is_error === true ||
|
||||
payload.is_error === true ||
|
||||
payload.error !== undefined ||
|
||||
asString(payload.status).toLowerCase() === "error";
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: callId,
|
||||
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
|
||||
}
|
||||
|
||||
function readSessionId(parsed: Record<string, unknown>): string {
|
||||
return (
|
||||
asString(parsed.session_id) ||
|
||||
asString(parsed.sessionId) ||
|
||||
asString(parsed.sessionID) ||
|
||||
asString(parsed.checkpoint_id) ||
|
||||
asString(parsed.thread_id)
|
||||
);
|
||||
}
|
||||
|
||||
function readUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
return {
|
||||
inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
|
||||
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
||||
cachedTokens: asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "system") {
|
||||
const subtype = asString(parsed.subtype);
|
||||
if (subtype === "init") {
|
||||
const sessionId = readSessionId(parsed);
|
||||
return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
|
||||
}
|
||||
if (subtype === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
return parseAssistantMessage(parsed.message, ts);
|
||||
}
|
||||
|
||||
if (type === "user") {
|
||||
return collectTextEntries(parsed.message, ts, "user");
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||
}
|
||||
|
||||
if (type === "tool_call") {
|
||||
return parseTopLevelToolEvent(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = readUsage(parsed);
|
||||
const errors = parsed.is_error === true
|
||||
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
||||
: [];
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedTokens: usage.cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype: asString(parsed.subtype, "result"),
|
||||
isError: parsed.is_error === true,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/openclaw-gateway"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
if (!debug) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[openclaw-gateway:event]")) {
|
||||
console.log(pc.cyan(line));
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith("[openclaw-gateway]")) {
|
||||
console.log(pc.blue(line));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.gray(line));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printOpenClawGatewayStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
export const type = "openclaw_gateway";
|
||||
export const label = "OpenClaw Gateway";
|
||||
|
||||
export const models: { id: string; label: string }[] = [];
|
||||
|
||||
export const agentConfigurationDoc = `# openclaw_gateway agent configuration
|
||||
|
||||
Adapter: openclaw_gateway
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol.
|
||||
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
||||
|
||||
Don't use when:
|
||||
- You only expose OpenClaw HTTP endpoints.
|
||||
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||
|
||||
Core fields:
|
||||
- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://)
|
||||
- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth
|
||||
- authToken (string, optional): shared gateway token override
|
||||
- password (string, optional): gateway shared password, if configured
|
||||
|
||||
Gateway connect identity fields:
|
||||
- clientId (string, optional): gateway client id (default gateway-client)
|
||||
- clientMode (string, optional): gateway client mode (default backend)
|
||||
- clientVersion (string, optional): client version string
|
||||
- role (string, optional): gateway role (default operator)
|
||||
- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"])
|
||||
- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false)
|
||||
|
||||
Request behavior fields:
|
||||
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||
- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments
|
||||
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||
- autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true)
|
||||
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
||||
|
||||
Session routing fields:
|
||||
- sessionKeyStrategy (string, optional): issue (default), fixed, or run
|
||||
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||
|
||||
Standard outbound payload additions:
|
||||
- paperclip (object): standardized Paperclip context added to every gateway agent request
|
||||
- paperclip.workspace (object, optional): resolved execution workspace for this run
|
||||
- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run
|
||||
- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace
|
||||
|
||||
Standard result metadata supported:
|
||||
- meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports
|
||||
- meta.previewUrl (string, optional): shorthand single preview URL
|
||||
- meta.previewUrls (string[], optional): shorthand multiple preview URLs
|
||||
`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
@@ -0,0 +1,317 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const value = hostname.trim().toLowerCase();
|
||||
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||
}
|
||||
|
||||
function toStringRecord(value: unknown): Record<string, string> {
|
||||
const parsed = parseObject(value);
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(parsed)) {
|
||||
if (typeof entry === "string") out[key] = entry;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||||
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function tokenFromAuthHeader(rawHeader: string | null): string | null {
|
||||
if (!rawHeader) return null;
|
||||
const trimmed = rawHeader.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
||||
return match ? nonEmpty(match[1]) : trimmed;
|
||||
}
|
||||
|
||||
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
|
||||
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
||||
if (explicit) return explicit;
|
||||
|
||||
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
||||
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
|
||||
|
||||
const authHeader =
|
||||
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||
headerMapGetIgnoreCase(headers, "authorization");
|
||||
return tokenFromAuthHeader(authHeader);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function rawDataToString(data: unknown): string {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(
|
||||
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
|
||||
).toString("utf8");
|
||||
}
|
||||
return String(data ?? "");
|
||||
}
|
||||
|
||||
async function probeGateway(input: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
authToken: string | null;
|
||||
role: string;
|
||||
scopes: string[];
|
||||
timeoutMs: number;
|
||||
}): Promise<"ok" | "challenge_only" | "failed"> {
|
||||
return await new Promise((resolve) => {
|
||||
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve("failed");
|
||||
}, input.timeoutMs);
|
||||
|
||||
let completed = false;
|
||||
|
||||
const finish = (status: "ok" | "challenge_only" | "failed") => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(status);
|
||||
};
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(raw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const event = asRecord(parsed);
|
||||
if (event?.type === "event" && event.event === "connect.challenge") {
|
||||
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
|
||||
if (!nonce) {
|
||||
finish("failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const connectId = randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: connectId,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
version: "paperclip-probe",
|
||||
platform: process.platform,
|
||||
mode: "probe",
|
||||
},
|
||||
role: input.role,
|
||||
scopes: input.scopes,
|
||||
...(input.authToken
|
||||
? {
|
||||
auth: {
|
||||
token: input.authToken,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.type === "res") {
|
||||
if (event.ok === true) {
|
||||
finish("ok");
|
||||
} else {
|
||||
finish("challenge_only");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("error", () => {
|
||||
finish("failed");
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
if (!completed) finish("failed");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const urlValue = asString(config.url, "").trim();
|
||||
|
||||
if (!urlValue) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_missing",
|
||||
level: "error",
|
||||
message: "OpenClaw gateway adapter requires a WebSocket URL.",
|
||||
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
|
||||
});
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL | null = null;
|
||||
try {
|
||||
url = new URL(urlValue);
|
||||
} catch {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_invalid",
|
||||
level: "error",
|
||||
message: `Invalid URL: ${urlValue}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_protocol_invalid",
|
||||
level: "error",
|
||||
message: `Unsupported URL protocol: ${url.protocol}`,
|
||||
hint: "Use ws:// or wss://.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_url_valid",
|
||||
level: "info",
|
||||
message: `Configured gateway URL: ${url.toString()}`,
|
||||
});
|
||||
|
||||
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_plaintext_remote_ws",
|
||||
level: "warn",
|
||||
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
|
||||
hint: "Prefer wss:// for remote gateways.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const headers = toStringRecord(config.headers);
|
||||
const authToken = resolveAuthToken(config, headers);
|
||||
const password = nonEmpty(config.password);
|
||||
const role = nonEmpty(config.role) ?? "operator";
|
||||
const scopes = toStringArray(config.scopes);
|
||||
|
||||
if (authToken || password) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_auth_present",
|
||||
level: "info",
|
||||
message: "Gateway credentials are configured.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_auth_missing",
|
||||
level: "warn",
|
||||
message: "No gateway credentials detected in adapter config.",
|
||||
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
|
||||
try {
|
||||
const probeResult = await probeGateway({
|
||||
url: url.toString(),
|
||||
headers,
|
||||
authToken,
|
||||
role,
|
||||
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
|
||||
timeoutMs: 3_000,
|
||||
});
|
||||
|
||||
if (probeResult === "ok") {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_ok",
|
||||
level: "info",
|
||||
message: "Gateway connect probe succeeded.",
|
||||
});
|
||||
} else if (probeResult === "challenge_only") {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_challenge_only",
|
||||
level: "warn",
|
||||
message: "Gateway challenge was received, but connect probe was rejected.",
|
||||
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_failed",
|
||||
level: "warn",
|
||||
message: "Gateway probe failed.",
|
||||
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "openclaw_gateway_probe_error",
|
||||
level: "warn",
|
||||
message: err instanceof Error ? err.message : "Gateway probe failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function normalizeOpenClawGatewayStreamLine(rawLine: string): {
|
||||
stream: "stdout" | "stderr" | null;
|
||||
line: string;
|
||||
} {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) return { stream: null, line: "" };
|
||||
|
||||
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i);
|
||||
if (!prefixed) {
|
||||
return { stream: null, line: trimmed };
|
||||
}
|
||||
|
||||
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
|
||||
const line = (prefixed[2] ?? "").trim();
|
||||
return { stream, line };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.timeoutSec = 120;
|
||||
ac.waitTimeoutMs = 120000;
|
||||
ac.sessionKeyStrategy = "issue";
|
||||
ac.role = "operator";
|
||||
ac.scopes = ["operator.admin"];
|
||||
const payloadTemplate = parseJsonObject(v.payloadTemplateJson ?? "");
|
||||
if (payloadTemplate) ac.payloadTemplate = payloadTemplate;
|
||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
||||
ac.workspaceRuntime = runtimeServices;
|
||||
}
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenClawGatewayConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s);
|
||||
if (!match) return [{ kind: "stdout", ts, text: line }];
|
||||
|
||||
const stream = asString(match[2]).toLowerCase();
|
||||
const data = asRecord(safeJsonParse(asString(match[3]).trim()));
|
||||
|
||||
if (stream === "assistant") {
|
||||
const delta = asString(data?.delta);
|
||||
if (delta.length > 0) {
|
||||
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||
}
|
||||
|
||||
const text = asString(data?.text);
|
||||
if (text.length > 0) {
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (stream === "error") {
|
||||
const message = asString(data?.error) || asString(data?.message);
|
||||
return message ? [{ kind: "stderr", ts, text: message }] : [];
|
||||
}
|
||||
|
||||
if (stream === "lifecycle") {
|
||||
const phase = asString(data?.phase).toLowerCase();
|
||||
const message = asString(data?.error) || asString(data?.message);
|
||||
if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) {
|
||||
return [{ kind: "stderr", ts, text: message }];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const normalized = normalizeOpenClawGatewayStreamLine(line);
|
||||
if (normalized.stream === "stderr") {
|
||||
return [{ kind: "stderr", ts, text: normalized.line }];
|
||||
}
|
||||
|
||||
const trimmed = normalized.line.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
if (trimmed.startsWith("[openclaw-gateway:event]")) {
|
||||
return parseAgentEventLine(trimmed, ts);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[openclaw-gateway]")) {
|
||||
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/opencode-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const data = asRecord(rec.data);
|
||||
const message =
|
||||
asString(rec.message) ||
|
||||
asString(data?.message) ||
|
||||
asString(rec.name) ||
|
||||
"";
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
console.log(pc.blue(`step started${sessionId ? ` (session: ${sessionId})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "reasoning") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tool = asString(part?.tool, "tool");
|
||||
const callID = asString(part?.callID);
|
||||
const state = asRecord(part?.state);
|
||||
const status = asString(state?.status);
|
||||
const isError = status === "error";
|
||||
const metadata = asRecord(state?.metadata);
|
||||
|
||||
console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`));
|
||||
|
||||
if (status) {
|
||||
const metaParts = [`status=${status}`];
|
||||
if (metadata) {
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`));
|
||||
}
|
||||
|
||||
const output = (asString(state?.output) || asString(state?.error)).trim();
|
||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const input = asNumber(tokens?.input, 0);
|
||||
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||
const cached = asNumber(cache?.read, 0);
|
||||
const cost = asNumber(part?.cost, 0);
|
||||
const reason = asString(part?.reason, "step");
|
||||
console.log(pc.blue(`step finished: reason=${reason}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { printOpenCodeStreamEvent } from "./format-event.js";
|
||||
@@ -0,0 +1,40 @@
|
||||
export const type = "opencode_local";
|
||||
export const label = "OpenCode (local)";
|
||||
|
||||
export const models: Array<{ id: string; label: string }> = [];
|
||||
|
||||
export const agentConfigurationDoc = `# opencode_local agent configuration
|
||||
|
||||
Adapter: opencode_local
|
||||
|
||||
Use when:
|
||||
- You want Paperclip to run OpenCode locally as the agent runtime
|
||||
- You want provider/model routing in OpenCode format (provider/model)
|
||||
- You want OpenCode session resume across heartbeats via --session
|
||||
|
||||
Don't use when:
|
||||
- You need webhook-style external invocation (use openclaw_gateway or http)
|
||||
- You only need one-shot shell commands (use process)
|
||||
- OpenCode CLI is not installed on the machine
|
||||
|
||||
Core fields:
|
||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
||||
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
|
||||
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- command (string, optional): defaults to "opencode"
|
||||
- extraArgs (string[], optional): additional CLI args
|
||||
- env (object, optional): KEY=VALUE environment variables
|
||||
|
||||
Operational fields:
|
||||
- timeoutSec (number, optional): run timeout in seconds
|
||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||
|
||||
Notes:
|
||||
- OpenCode supports multiple providers and models. Use \
|
||||
\`opencode models\` to list available options in provider/model format.
|
||||
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
|
||||
- Runs are executed with: opencode run --format json ...
|
||||
- Sessions are resumed with --session when stored session cwd matches current cwd.
|
||||
`;
|
||||
@@ -0,0 +1,402 @@
|
||||
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 {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
joinPromptSections,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePaperclipSkillSymlink,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
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 inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
async function ensureOpenCodeSkillsInjected(
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
skillsEntries: Array<{ key: string; runtimeName: string; source: string }>,
|
||||
desiredSkillNames?: string[],
|
||||
) {
|
||||
const skillsHome = claudeSkillsHome();
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
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();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
|
||||
await ensureOpenCodeSkillsInjected(
|
||||
onLog,
|
||||
openCodeSkillEntries,
|
||||
desiredOpenCodeSkillNames,
|
||||
);
|
||||
|
||||
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)
|
||||
: [];
|
||||
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const runtimeEnv = Object.fromEntries(
|
||||
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\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 = (() => {
|
||||
if (!resolvedInstructionsFilePath) return [] as string[];
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
|
||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
|
||||
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 renderedPrompt = renderTemplate(promptTemplate, templateData);
|
||||
const renderedBootstrapPrompt =
|
||||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const prompt = joinPromptSections([
|
||||
instructionsPrefix,
|
||||
renderedBootstrapPrompt,
|
||||
sessionHandoffNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
promptChars: prompt.length,
|
||||
instructionsChars: instructionsPrefix.length,
|
||||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["run", "--format", "json"];
|
||||
if (resumeSessionId) args.push("--session", resumeSessionId);
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "opencode_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
promptMetrics,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
stdin: prompt,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onSpawn,
|
||||
onLog,
|
||||
});
|
||||
return {
|
||||
proc,
|
||||
rawStderr: proc.stderr,
|
||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
|
||||
rawStderr: string;
|
||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId =
|
||||
attempt.parsed.sessionId ??
|
||||
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} 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,
|
||||
errorMessage: (synthesizedExitCode ?? 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: parseModelProvider(modelId),
|
||||
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
|
||||
model: modelId,
|
||||
billingType: "unknown",
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export { execute } from "./execute.js";
|
||||
export { listOpenCodeSkills, syncOpenCodeSkills } from "./skills.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export {
|
||||
listOpenCodeModels,
|
||||
discoverOpenCodeModels,
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureOpenCodeModelConfiguredAndAvailable,
|
||||
listOpenCodeModels,
|
||||
resetOpenCodeModelsCacheForTests,
|
||||
} from "./models.js";
|
||||
|
||||
describe("openCode models", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
|
||||
resetOpenCodeModelsCacheForTests();
|
||||
});
|
||||
|
||||
it("returns an empty list when discovery command is unavailable", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
await expect(listOpenCodeModels()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects when model is missing", async () => {
|
||||
await expect(
|
||||
ensureOpenCodeModelConfiguredAndAvailable({ model: "" }),
|
||||
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
|
||||
});
|
||||
|
||||
it("rejects when discovery cannot run for configured model", async () => {
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
|
||||
await expect(
|
||||
ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: "openai/gpt-5",
|
||||
}),
|
||||
).rejects.toThrow("Failed to start command");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import type { AdapterModel } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const MODELS_CACHE_TTL_MS = 60_000;
|
||||
const MODELS_DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
|
||||
function resolveOpenCodeCommand(input: unknown): string {
|
||||
const envOverride =
|
||||
typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
|
||||
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
|
||||
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
|
||||
: "opencode";
|
||||
return asString(input, envOverride);
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
|
||||
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
|
||||
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID", "HOME"]);
|
||||
|
||||
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: AdapterModel[] = [];
|
||||
for (const model of models) {
|
||||
const id = model.id.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push({ id, label: model.label.trim() || id });
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function sortModels(models: AdapterModel[]): AdapterModel[] {
|
||||
return [...models].sort((a, b) =>
|
||||
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function parseModelsOutput(stdout: string): AdapterModel[] {
|
||||
const parsed: AdapterModel[] = [];
|
||||
for (const raw of stdout.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
|
||||
if (!firstToken.includes("/")) continue;
|
||||
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
|
||||
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
|
||||
if (!provider || !model) continue;
|
||||
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
|
||||
}
|
||||
return dedupeModels(parsed);
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
|
||||
? (input as Record<string, unknown>)
|
||||
: {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envInput)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function isVolatileEnvKey(key: string): boolean {
|
||||
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
|
||||
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hashValue(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
|
||||
const envKey = Object.entries(env)
|
||||
.filter(([key]) => !isVolatileEnvKey(key))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${hashValue(value)}`)
|
||||
.join("\n");
|
||||
return `${command}\n${cwd}\n${envKey}`;
|
||||
}
|
||||
|
||||
function pruneExpiredDiscoveryCache(now: number) {
|
||||
for (const [key, value] of discoveryCache.entries()) {
|
||||
if (value.expiresAt <= now) discoveryCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModels(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
// Ensure HOME points to the actual running user's home directory.
|
||||
// When the server is started via `runuser -u <user>`, HOME may still
|
||||
// reflect the parent process (e.g. /root), causing OpenCode to miss
|
||||
// provider auth credentials stored under the target user's home.
|
||||
let resolvedHome: string | undefined;
|
||||
try {
|
||||
resolvedHome = os.userInfo().homedir || undefined;
|
||||
} catch {
|
||||
// os.userInfo() throws a SystemError when the current UID has no
|
||||
// /etc/passwd entry (e.g. `docker run --user 1234` with a minimal
|
||||
// image). Fall back to process.env.HOME.
|
||||
}
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env, ...(resolvedHome ? { HOME: resolvedHome } : {}) }));
|
||||
|
||||
const result = await runChildProcess(
|
||||
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
["models"],
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000,
|
||||
graceSec: 3,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
if (result.timedOut) {
|
||||
throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`);
|
||||
}
|
||||
if ((result.exitCode ?? 1) !== 0) {
|
||||
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
|
||||
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
|
||||
}
|
||||
|
||||
return sortModels(parseModelsOutput(result.stdout));
|
||||
}
|
||||
|
||||
export async function discoverOpenCodeModelsCached(input: {
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
} = {}): Promise<AdapterModel[]> {
|
||||
const command = resolveOpenCodeCommand(input.command);
|
||||
const cwd = asString(input.cwd, process.cwd());
|
||||
const env = normalizeEnv(input.env);
|
||||
const key = discoveryCacheKey(command, cwd, env);
|
||||
const now = Date.now();
|
||||
pruneExpiredDiscoveryCache(now);
|
||||
const cached = discoveryCache.get(key);
|
||||
if (cached && cached.expiresAt > now) return cached.models;
|
||||
|
||||
const models = await discoverOpenCodeModels({ command, cwd, env });
|
||||
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
|
||||
model?: unknown;
|
||||
command?: unknown;
|
||||
cwd?: unknown;
|
||||
env?: unknown;
|
||||
}): Promise<AdapterModel[]> {
|
||||
const model = asString(input.model, "").trim();
|
||||
if (!model) {
|
||||
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
|
||||
}
|
||||
|
||||
const models = await discoverOpenCodeModelsCached({
|
||||
command: input.command,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth.");
|
||||
}
|
||||
|
||||
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: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export async function listOpenCodeModels(): Promise<AdapterModel[]> {
|
||||
try {
|
||||
return await discoverOpenCodeModelsCached();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resetOpenCodeModelsCacheForTests() {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
|
||||
describe("parseOpenCodeJsonl", () => {
|
||||
it("parses assistant text, usage, cost, and errors", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "text",
|
||||
sessionID: "session_123",
|
||||
part: { text: "Hello from OpenCode" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "step_finish",
|
||||
sessionID: "session_123",
|
||||
part: {
|
||||
reason: "done",
|
||||
cost: 0.0025,
|
||||
tokens: {
|
||||
input: 120,
|
||||
output: 40,
|
||||
reasoning: 10,
|
||||
cache: { read: 20, write: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
sessionID: "session_123",
|
||||
error: { message: "model unavailable" },
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const parsed = parseOpenCodeJsonl(stdout);
|
||||
expect(parsed.sessionId).toBe("session_123");
|
||||
expect(parsed.summary).toBe("Hello from OpenCode");
|
||||
expect(parsed.usage).toEqual({
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 20,
|
||||
outputTokens: 50,
|
||||
});
|
||||
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
|
||||
expect(parsed.errorMessage).toContain("model unavailable");
|
||||
});
|
||||
|
||||
it("detects unknown session errors", () => {
|
||||
expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true);
|
||||
expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true);
|
||||
expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message = asString(rec.message, "").trim();
|
||||
if (message) return message;
|
||||
const data = parseObject(rec.data);
|
||||
const nestedMessage = asString(data.message, "").trim();
|
||||
if (nestedMessage) return nestedMessage;
|
||||
const name = asString(rec.name, "").trim();
|
||||
if (name) return name;
|
||||
const code = asString(rec.code, "").trim();
|
||||
if (code) return code;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenCodeJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
let costUsd = 0;
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const currentSessionId = asString(event.sessionID, "").trim();
|
||||
if (currentSessionId) sessionId = currentSessionId;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = parseObject(event.part);
|
||||
const tokens = parseObject(part.tokens);
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
|
||||
costUsd += asNumber(part.cost, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
const part = parseObject(event.part);
|
||||
const state = parseObject(part.state);
|
||||
if (asString(state.status, "") === "error") {
|
||||
const text = asString(state.error, "").trim();
|
||||
if (text) errors.push(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(event.error ?? event.message).trim();
|
||||
if (text) errors.push(text);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd,
|
||||
errorMessage: errors.length > 0 ? errors.join("\n") : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AdapterSkillContext,
|
||||
AdapterSkillSnapshot,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
buildPersistentSkillSnapshot,
|
||||
ensurePaperclipSkillSymlink,
|
||||
readPaperclipRuntimeSkillEntries,
|
||||
readInstalledSkillTargets,
|
||||
resolvePaperclipDesiredSkillNames,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function resolveOpenCodeSkillsHome(config: Record<string, unknown>) {
|
||||
const env =
|
||||
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
|
||||
? (config.env as Record<string, unknown>)
|
||||
: {};
|
||||
const configuredHome = asString(env.HOME);
|
||||
const home = configuredHome ? path.resolve(configuredHome) : os.homedir();
|
||||
return path.join(home, ".claude", "skills");
|
||||
}
|
||||
|
||||
async function buildOpenCodeSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
||||
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(config);
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
return buildPersistentSkillSnapshot({
|
||||
adapterType: "opencode_local",
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
installed,
|
||||
skillsHome,
|
||||
locationLabel: "~/.claude/skills",
|
||||
installedDetail: "Installed in the shared Claude/OpenCode skills home.",
|
||||
missingDetail: "Configured but not currently linked into the shared Claude/OpenCode skills home.",
|
||||
externalConflictDetail: "Skill name is occupied by an external installation in the shared skills home.",
|
||||
externalDetail: "Installed outside Paperclip management in the shared skills home.",
|
||||
warnings: [
|
||||
"OpenCode currently uses the shared Claude skills home (~/.claude/skills).",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function listOpenCodeSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
|
||||
return buildOpenCodeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export async function syncOpenCodeSkills(
|
||||
ctx: AdapterSkillContext,
|
||||
desiredSkills: string[],
|
||||
): Promise<AdapterSkillSnapshot> {
|
||||
const availableEntries = await readPaperclipRuntimeSkillEntries(ctx.config, __moduleDir);
|
||||
const desiredSet = new Set([
|
||||
...desiredSkills,
|
||||
...availableEntries.filter((entry) => entry.required).map((entry) => entry.key),
|
||||
]);
|
||||
const skillsHome = resolveOpenCodeSkillsHome(ctx.config);
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const installed = await readInstalledSkillTargets(skillsHome);
|
||||
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
|
||||
|
||||
for (const available of availableEntries) {
|
||||
if (!desiredSet.has(available.key)) continue;
|
||||
const target = path.join(skillsHome, available.runtimeName);
|
||||
await ensurePaperclipSkillSymlink(available.source, target);
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
const available = availableByRuntimeName.get(name);
|
||||
if (!available) continue;
|
||||
if (desiredSet.has(available.key)) continue;
|
||||
if (installedEntry.targetPath !== available.source) continue;
|
||||
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
|
||||
}
|
||||
|
||||
return buildOpenCodeSkillSnapshot(ctx.config);
|
||||
}
|
||||
|
||||
export function resolveOpenCodeDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; required?: boolean }>,
|
||||
) {
|
||||
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
|
||||
}
|
||||
|
||||
function normalizeEnv(input: unknown): Record<string, string> {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const OPENCODE_AUTH_REQUIRED_RE =
|
||||
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "opencode");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
|
||||
checks.push({
|
||||
code: "opencode_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
|
||||
const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null;
|
||||
if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY override is empty.",
|
||||
hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.",
|
||||
});
|
||||
}
|
||||
|
||||
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
|
||||
|
||||
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
|
||||
if (cwdInvalid) {
|
||||
checks.push({
|
||||
code: "opencode_command_skipped",
|
||||
level: "warn",
|
||||
message: "Skipped command check because working directory validation failed.",
|
||||
detail: command,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "opencode_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||
|
||||
let modelValidationPassed = false;
|
||||
const configuredModel = asString(config.model, "").trim();
|
||||
|
||||
if (canRunProbe && configuredModel) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "opencode_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_models_empty",
|
||||
level: "error",
|
||||
message: "OpenCode returned no models.",
|
||||
hint: "Run `opencode models` and verify provider authentication.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: "The configured model was not found by the provider.",
|
||||
detail: errMsg,
|
||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_models_discovery_failed",
|
||||
level: "error",
|
||||
message: errMsg || "OpenCode model discovery failed.",
|
||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (canRunProbe && !configuredModel) {
|
||||
try {
|
||||
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
|
||||
if (discovered.length > 0) {
|
||||
checks.push({
|
||||
code: "opencode_models_discovered",
|
||||
level: "info",
|
||||
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
if (/ProviderModelNotFoundError/i.test(errMsg)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: "The configured model was not found by the provider.",
|
||||
detail: errMsg,
|
||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_models_discovery_failed",
|
||||
level: "warn",
|
||||
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
|
||||
hint: "Run `opencode models` manually to verify provider auth and config.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
|
||||
if (!configuredModel && !modelUnavailable) {
|
||||
// No model configured – skip model requirement if no model-related checks exist
|
||||
} else if (configuredModel && canRunProbe) {
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
model: configuredModel,
|
||||
command,
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
});
|
||||
checks.push({
|
||||
code: "opencode_model_configured",
|
||||
level: "info",
|
||||
message: `Configured model: ${configuredModel}`,
|
||||
});
|
||||
modelValidationPassed = true;
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_model_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Configured model is unavailable.",
|
||||
hint: "Run `opencode models` and choose a currently available provider/model ID.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (canRunProbe && modelValidationPassed) {
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
const variant = asString(config.variant, "").trim();
|
||||
const probeModel = configuredModel;
|
||||
|
||||
const args = ["run", "--format", "json"];
|
||||
args.push("--model", probeModel);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
try {
|
||||
const probe = await runChildProcess(
|
||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env: runtimeEnv,
|
||||
timeoutSec: 60,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "OpenCode hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "OpenCode hello probe succeeded."
|
||||
: "OpenCode probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
|
||||
}),
|
||||
});
|
||||
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_model_unavailable",
|
||||
level: "warn",
|
||||
message: "The configured model was not found by the provider.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode models` and choose an available provider/model ID.",
|
||||
});
|
||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "OpenCode is installed, but provider authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
hint: "Run `opencode run --format json` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
||||
const env: Record<string, unknown> = {};
|
||||
for (const [key, raw] of Object.entries(bindings)) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
if (typeof raw === "string") {
|
||||
env[key] = { type: "plain", value: raw };
|
||||
continue;
|
||||
}
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
||||
const rec = raw as Record<string, unknown>;
|
||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
||||
env[key] = { type: "plain", value: rec.value };
|
||||
continue;
|
||||
}
|
||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
||||
env[key] = {
|
||||
type: "secret_ref",
|
||||
secretId: rec.secretId,
|
||||
...(typeof rec.version === "number" || rec.version === "latest"
|
||||
? { version: rec.version }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
|
||||
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
|
||||
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 20;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
const legacy = parseEnvVars(v.envVars);
|
||||
for (const [key, value] of Object.entries(legacy)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
||||
env[key] = { type: "plain", value };
|
||||
}
|
||||
}
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { parseOpenCodeStdoutLine } from "./parse-stdout.js";
|
||||
export { buildOpenCodeLocalConfig } from "./build-config.js";
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const data = asRecord(rec.data);
|
||||
const msg =
|
||||
asString(rec.message) ||
|
||||
asString(data?.message) ||
|
||||
asString(rec.name) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const part = asRecord(parsed.part);
|
||||
if (!part) return [{ kind: "system", ts, text: "tool event" }];
|
||||
|
||||
const toolName = asString(part.tool, "tool");
|
||||
const state = asRecord(part.state);
|
||||
const input = state?.input ?? {};
|
||||
const callEntry: TranscriptEntry = {
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: toolName,
|
||||
toolUseId: asString(part.callID) || asString(part.id) || undefined,
|
||||
input,
|
||||
};
|
||||
|
||||
const status = asString(state?.status);
|
||||
if (status !== "completed" && status !== "error") return [callEntry];
|
||||
|
||||
const rawOutput =
|
||||
asString(state?.output) ||
|
||||
asString(state?.error) ||
|
||||
asString(part.title) ||
|
||||
`${toolName} ${status}`;
|
||||
|
||||
const metadata = asRecord(state?.metadata);
|
||||
const headerParts: string[] = [`status: ${status}`];
|
||||
if (metadata) {
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim();
|
||||
|
||||
return [
|
||||
callEntry,
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: asString(part.callID) || asString(part.id, toolName),
|
||||
content,
|
||||
isError: status === "error",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "text") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "reasoning") {
|
||||
const part = asRecord(parsed.part);
|
||||
const text = asString(part?.text).trim();
|
||||
if (!text) return [];
|
||||
return [{ kind: "thinking", ts, text }];
|
||||
}
|
||||
|
||||
if (type === "tool_use") {
|
||||
return parseToolUse(parsed, ts);
|
||||
}
|
||||
|
||||
if (type === "step_start") {
|
||||
const sessionId = asString(parsed.sessionID);
|
||||
return [
|
||||
{
|
||||
kind: "system",
|
||||
ts,
|
||||
text: `step started${sessionId ? ` (${sessionId})` : ""}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = asRecord(parsed.part);
|
||||
const tokens = asRecord(part?.tokens);
|
||||
const cache = asRecord(tokens?.cache);
|
||||
const reason = asString(part?.reason, "step");
|
||||
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
|
||||
return [
|
||||
{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: reason,
|
||||
inputTokens: asNumber(tokens?.input, 0),
|
||||
outputTokens: output,
|
||||
cachedTokens: asNumber(cache?.read, 0),
|
||||
costUsd: asNumber(part?.cost, 0),
|
||||
subtype: reason,
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const text = errorText(parsed.error ?? parsed.message);
|
||||
return [{ kind: "stderr", ts, text: text || line }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-pi-local",
|
||||
"version": "0.3.1",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/adapters/pi-local"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./ui": "./src/ui/index.ts",
|
||||
"./cli": "./src/cli/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server/index.d.ts",
|
||||
"import": "./dist/server/index.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function printPiStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
console.log(line);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "agent_start") {
|
||||
console.log(pc.blue("Pi agent started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "agent_end") {
|
||||
console.log(pc.blue("Pi agent finished"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn_start") {
|
||||
console.log(pc.blue("Turn started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn_end") {
|
||||
const message = asRecord(parsed.message);
|
||||
if (message) {
|
||||
const content = message.content as string | Array<{ type: string; text?: string }>;
|
||||
const text = extractTextContent(content);
|
||||
if (text) {
|
||||
console.log(pc.green(`assistant: ${text}`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "message_update") {
|
||||
const assistantEvent = asRecord(parsed.assistantMessageEvent);
|
||||
if (assistantEvent) {
|
||||
const msgType = asString(assistantEvent.type);
|
||||
if (msgType === "text_delta") {
|
||||
const delta = asString(assistantEvent.delta);
|
||||
if (delta) {
|
||||
console.log(pc.green(delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_execution_start") {
|
||||
const toolName = asString(parsed.toolName);
|
||||
const args = parsed.args;
|
||||
console.log(pc.yellow(`tool_start: ${toolName}`));
|
||||
if (args !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(args, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(args)));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "tool_execution_end") {
|
||||
const result = parsed.result;
|
||||
const isError = parsed.isError === true;
|
||||
const output = typeof result === "string" ? result : JSON.stringify(result);
|
||||
if (output) {
|
||||
console.log((isError ? pc.red : pc.gray)(output));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
}
|
||||
Loaded 100 of 1081 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user