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