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:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-25 23:46:16 -04:00
1 parent a5b8fcf9d3
commit aa1a78d7d9
1081 files changed
+445341

No files matched your search

@@ -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;
}