Move adapter implementations into shared workspace packages

Extract claude-local and codex-local adapter code from cli/server/ui
into packages/adapters/ and packages/adapter-utils/. CLI, server, and
UI now import shared adapter logic instead of duplicating it. Removes
~1100 lines of duplicated code across packages. Register new packages
in pnpm workspace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-18 14:23:16 -06:00
parent 47ccd946b6
commit 631c859b89
49 changed files with 656 additions and 381 deletions

View File

@@ -13,6 +13,9 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^0.10.0", "@clack/prompts": "^0.10.0",
"@paperclip/adapter-claude-local": "workspace:*",
"@paperclip/adapter-codex-local": "workspace:*",
"@paperclip/adapter-utils": "workspace:*",
"@paperclip/db": "workspace:*", "@paperclip/db": "workspace:*",
"@paperclip/shared": "workspace:*", "@paperclip/shared": "workspace:*",
"commander": "^13.1.0", "commander": "^13.1.0",

View File

@@ -1,7 +0,0 @@
import type { CLIAdapterModule } from "../types.js";
import { printClaudeStreamEvent } from "./format-event.js";
export const claudeLocalCLIAdapter: CLIAdapterModule = {
type: "claude_local",
formatStdoutEvent: printClaudeStreamEvent,
};

View File

@@ -1,7 +0,0 @@
import type { CLIAdapterModule } from "../types.js";
import { printCodexStreamEvent } from "./format-event.js";
export const codexLocalCLIAdapter: CLIAdapterModule = {
type: "codex_local",
formatStdoutEvent: printCodexStreamEvent,
};

View File

@@ -1,4 +1,4 @@
import type { CLIAdapterModule } from "../types.js"; import type { CLIAdapterModule } from "@paperclip/adapter-utils";
import { printHttpStdoutEvent } from "./format-event.js"; import { printHttpStdoutEvent } from "./format-event.js";
export const httpCLIAdapter: CLIAdapterModule = { export const httpCLIAdapter: CLIAdapterModule = {

View File

@@ -1,2 +1,2 @@
export { getCLIAdapter } from "./registry.js"; export { getCLIAdapter } from "./registry.js";
export type { CLIAdapterModule } from "./types.js"; export type { CLIAdapterModule } from "@paperclip/adapter-utils";

View File

@@ -1,4 +1,4 @@
import type { CLIAdapterModule } from "../types.js"; import type { CLIAdapterModule } from "@paperclip/adapter-utils";
import { printProcessStdoutEvent } from "./format-event.js"; import { printProcessStdoutEvent } from "./format-event.js";
export const processCLIAdapter: CLIAdapterModule = { export const processCLIAdapter: CLIAdapterModule = {

View File

@@ -1,9 +1,19 @@
import type { CLIAdapterModule } from "./types.js"; import type { CLIAdapterModule } from "@paperclip/adapter-utils";
import { claudeLocalCLIAdapter } from "./claude-local/index.js"; import { printClaudeStreamEvent } from "@paperclip/adapter-claude-local/cli";
import { codexLocalCLIAdapter } from "./codex-local/index.js"; import { printCodexStreamEvent } from "@paperclip/adapter-codex-local/cli";
import { processCLIAdapter } from "./process/index.js"; import { processCLIAdapter } from "./process/index.js";
import { httpCLIAdapter } from "./http/index.js"; import { httpCLIAdapter } from "./http/index.js";
const claudeLocalCLIAdapter: CLIAdapterModule = {
type: "claude_local",
formatStdoutEvent: printClaudeStreamEvent,
};
const codexLocalCLIAdapter: CLIAdapterModule = {
type: "codex_local",
formatStdoutEvent: printCodexStreamEvent,
};
const adaptersByType = new Map<string, CLIAdapterModule>( const adaptersByType = new Map<string, CLIAdapterModule>(
[claudeLocalCLIAdapter, codexLocalCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), [claudeLocalCLIAdapter, codexLocalCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
); );

View File

@@ -1,4 +0,0 @@
export interface CLIAdapterModule {
type: string;
formatStdoutEvent: (line: string, debug: boolean) => void;
}

View File

@@ -0,0 +1,16 @@
{
"name": "@paperclip/adapter-utils",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,13 @@
export type {
AdapterAgent,
AdapterRuntime,
UsageSummary,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,
ServerAdapterModule,
TranscriptEntry,
StdoutLineParser,
CLIAdapterModule,
CreateConfigValues,
} from "./types.js";

View File

@@ -0,0 +1,250 @@
import { spawn, type ChildProcess } from "node:child_process";
import { constants as fsConstants, promises as fs } from "node:fs";
import path from "node:path";
export interface RunProcessResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
}
interface RunningProcess {
child: ChildProcess;
graceSec: number;
}
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;
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 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 vars: Record<string, string> = {
PAPERCLIP_AGENT_ID: agent.id,
PAPERCLIP_COMPANY_ID: agent.companyId,
};
const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://localhost:${process.env.PORT ?? 3100}`;
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";
}
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) {
if (!path.isAbsolute(cwd)) {
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
}
let stats;
try {
stats = await fs.stat(cwd);
} catch {
throw new Error(`Working directory does not exist: "${cwd}"`);
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: "${cwd}"`);
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const hasPathSeparator = command.includes("/") || command.includes("\\");
if (hasPathSeparator) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
try {
await fs.access(absolute, fsConstants.X_OK);
} catch {
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
return;
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const windowsExt = process.platform === "win32"
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
: [""];
for (const dir of dirs) {
for (const ext of windowsExt) {
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
try {
await fs.access(candidate, fsConstants.X_OK);
return;
} catch {
// continue scanning PATH
}
}
}
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;
},
): Promise<RunProcessResult> {
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
return new Promise<RunProcessResult>((resolve, reject) => {
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env });
const child = spawn(command, args, {
cwd: opts.cwd,
env: mergedEnv,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, Math.max(1, opts.timeoutSec) * 1000);
child.stdout?.on("data", (chunk) => {
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) => {
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) => {
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, signal) => {
clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
});
});
});
});
}

View File

@@ -0,0 +1,113 @@
// ---------------------------------------------------------------------------
// Minimal adapter-facing interfaces (no drizzle dependency)
// ---------------------------------------------------------------------------
export interface AdapterAgent {
id: string;
companyId: string;
name: string;
adapterType: string | null;
adapterConfig: unknown;
}
export interface AdapterRuntime {
sessionId: string | null;
}
// ---------------------------------------------------------------------------
// Execution types (moved from server/src/adapters/types.ts)
// ---------------------------------------------------------------------------
export interface UsageSummary {
inputTokens: number;
outputTokens: number;
cachedInputTokens?: number;
}
export interface AdapterExecutionResult {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
errorMessage?: string | null;
usage?: UsageSummary;
sessionId?: string | null;
provider?: string | null;
model?: string | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
summary?: string | null;
clearSession?: boolean;
}
export interface AdapterInvocationMeta {
adapterType: string;
command: string;
cwd?: string;
commandArgs?: string[];
env?: Record<string, string>;
prompt?: string;
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>;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
models?: { id: string; label: string }[];
}
// ---------------------------------------------------------------------------
// UI types (moved from ui/src/adapters/types.ts)
// ---------------------------------------------------------------------------
export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown }
| { 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;
promptTemplate: string;
model: string;
dangerouslySkipPermissions: boolean;
search: boolean;
dangerouslyBypassSandbox: boolean;
command: string;
args: string;
extraArgs: string;
envVars: string;
url: string;
bootstrapPrompt: string;
maxTurnsPerRun: number;
heartbeatEnabled: boolean;
intervalSec: number;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,22 @@
{
"name": "@paperclip/adapter-claude-local",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclip/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1 @@
export { printClaudeStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,8 @@
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-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
];

View File

@@ -1,5 +1,5 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
import type { RunProcessResult } from "../utils.js"; import type { RunProcessResult } from "@paperclip/adapter-utils/server-utils";
import { import {
asString, asString,
asNumber, asNumber,
@@ -14,7 +14,7 @@ import {
ensurePathInEnv, ensurePathInEnv,
renderTemplate, renderTemplate,
runChildProcess, runChildProcess,
} from "../utils.js"; } from "@paperclip/adapter-utils/server-utils";
import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js"; import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {

View File

@@ -0,0 +1,2 @@
export { execute } from "./execute.js";
export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";

View File

@@ -1,5 +1,5 @@
import type { UsageSummary } from "../types.js"; import type { UsageSummary } from "@paperclip/adapter-utils";
import { asString, asNumber, parseObject, parseJson } from "../utils.js"; import { asString, asNumber, parseObject, parseJson } from "@paperclip/adapter-utils/server-utils";
export function parseClaudeStreamJson(stdout: string) { export function parseClaudeStreamJson(stdout: string) {
let sessionId: string | null = null; let sessionId: string | null = null;

View File

@@ -1,4 +1,4 @@
import type { CreateConfigValues } from "../../components/AgentConfigForm"; import type { CreateConfigValues } from "@paperclip/adapter-utils";
function parseCommaArgs(value: string): string[] { function parseCommaArgs(value: string): string[] {
return value return value

View File

@@ -0,0 +1,2 @@
export { parseClaudeStdoutLine } from "./parse-stdout.js";
export { buildClaudeLocalConfig } from "./build-config.js";

View File

@@ -1,4 +1,4 @@
import type { TranscriptEntry } from "../types"; import type { TranscriptEntry } from "@paperclip/adapter-utils";
function asRecord(value: unknown): Record<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null; if (typeof value !== "object" || value === null || Array.isArray(value)) return null;

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,22 @@
{
"name": "@paperclip/adapter-codex-local",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclip/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1 @@
export { printCodexStreamEvent } from "./format-event.js";

View File

@@ -0,0 +1,8 @@
export const type = "codex_local";
export const label = "Codex (local)";
export const models = [
{ id: "o4-mini", label: "o4-mini" },
{ id: "o3", label: "o3" },
{ id: "codex-mini-latest", label: "Codex Mini" },
];

View File

@@ -1,4 +1,4 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
import { import {
asString, asString,
asNumber, asNumber,
@@ -12,7 +12,7 @@ import {
ensurePathInEnv, ensurePathInEnv,
renderTemplate, renderTemplate,
runChildProcess, runChildProcess,
} from "../utils.js"; } from "@paperclip/adapter-utils/server-utils";
import { parseCodexJsonl } from "./parse.js"; import { parseCodexJsonl } from "./parse.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {

View File

@@ -0,0 +1,2 @@
export { execute } from "./execute.js";
export { parseCodexJsonl } from "./parse.js";

View File

@@ -1,4 +1,4 @@
import { asString, asNumber, parseObject, parseJson } from "../utils.js"; import { asString, asNumber, parseObject, parseJson } from "@paperclip/adapter-utils/server-utils";
export function parseCodexJsonl(stdout: string) { export function parseCodexJsonl(stdout: string) {
let sessionId: string | null = null; let sessionId: string | null = null;

View File

@@ -1,4 +1,4 @@
import type { CreateConfigValues } from "../../components/AgentConfigForm"; import type { CreateConfigValues } from "@paperclip/adapter-utils";
function parseCommaArgs(value: string): string[] { function parseCommaArgs(value: string): string[] {
return value return value

View File

@@ -0,0 +1,2 @@
export { parseCodexStdoutLine } from "./parse-stdout.js";
export { buildCodexLocalConfig } from "./build-config.js";

View File

@@ -1,4 +1,4 @@
import type { TranscriptEntry } from "../types"; import type { TranscriptEntry } from "@paperclip/adapter-utils";
function safeJsonParse(text: string): unknown { function safeJsonParse(text: string): unknown {
try { try {

View File

@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

59
pnpm-lock.yaml generated
View File

@@ -20,6 +20,15 @@ importers:
'@clack/prompts': '@clack/prompts':
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.1 version: 0.10.1
'@paperclip/adapter-claude-local':
specifier: workspace:*
version: link:../packages/adapters/claude-local
'@paperclip/adapter-codex-local':
specifier: workspace:*
version: link:../packages/adapters/codex-local
'@paperclip/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
'@paperclip/db': '@paperclip/db':
specifier: workspace:* specifier: workspace:*
version: link:../packages/db version: link:../packages/db
@@ -43,6 +52,38 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
packages/adapter-utils:
devDependencies:
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/adapters/claude-local:
dependencies:
'@paperclip/adapter-utils':
specifier: workspace:*
version: link:../../adapter-utils
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/adapters/codex-local:
dependencies:
'@paperclip/adapter-utils':
specifier: workspace:*
version: link:../../adapter-utils
picocolors:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
typescript:
specifier: ^5.7.3
version: 5.9.3
packages/db: packages/db:
dependencies: dependencies:
'@paperclip/shared': '@paperclip/shared':
@@ -80,6 +121,15 @@ importers:
server: server:
dependencies: dependencies:
'@paperclip/adapter-claude-local':
specifier: workspace:*
version: link:../packages/adapters/claude-local
'@paperclip/adapter-codex-local':
specifier: workspace:*
version: link:../packages/adapters/codex-local
'@paperclip/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
'@paperclip/db': '@paperclip/db':
specifier: workspace:* specifier: workspace:*
version: link:../packages/db version: link:../packages/db
@@ -139,6 +189,15 @@ importers:
ui: ui:
dependencies: dependencies:
'@paperclip/adapter-claude-local':
specifier: workspace:*
version: link:../packages/adapters/claude-local
'@paperclip/adapter-codex-local':
specifier: workspace:*
version: link:../packages/adapters/codex-local
'@paperclip/adapter-utils':
specifier: workspace:*
version: link:../packages/adapter-utils
'@paperclip/shared': '@paperclip/shared':
specifier: workspace:* specifier: workspace:*
version: link:../packages/shared version: link:../packages/shared

View File

@@ -1,5 +1,6 @@
packages: packages:
- packages/* - packages/*
- packages/adapters/*
- server - server
- ui - ui
- cli - cli

View File

@@ -10,6 +10,9 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@paperclip/adapter-claude-local": "workspace:*",
"@paperclip/adapter-codex-local": "workspace:*",
"@paperclip/adapter-utils": "workspace:*",
"@paperclip/db": "workspace:*", "@paperclip/db": "workspace:*",
"@paperclip/shared": "workspace:*", "@paperclip/shared": "workspace:*",
"detect-port": "^2.1.0", "detect-port": "^2.1.0",

View File

@@ -1,12 +0,0 @@
import type { ServerAdapterModule } from "../types.js";
import { execute } from "./execute.js";
export const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute,
models: [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
],
};

View File

@@ -1,12 +0,0 @@
import type { ServerAdapterModule } from "../types.js";
import { execute } from "./execute.js";
export const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
execute,
models: [
{ id: "o4-mini", label: "o4-mini" },
{ id: "o3", label: "o3" },
{ id: "codex-mini-latest", label: "Codex Mini" },
],
};

View File

@@ -5,7 +5,7 @@ export type {
AdapterExecutionResult, AdapterExecutionResult,
AdapterInvocationMeta, AdapterInvocationMeta,
UsageSummary, UsageSummary,
AgentRecord, AdapterAgent,
AgentRuntimeStateRecord, AdapterRuntime,
} from "./types.js"; } from "@paperclip/adapter-utils";
export { runningProcesses } from "./utils.js"; export { runningProcesses } from "./utils.js";

View File

@@ -1,9 +1,23 @@
import type { ServerAdapterModule } from "./types.js"; import type { ServerAdapterModule } from "./types.js";
import { claudeLocalAdapter } from "./claude-local/index.js"; import { execute as claudeExecute } from "@paperclip/adapter-claude-local/server";
import { codexLocalAdapter } from "./codex-local/index.js"; import { models as claudeModels } from "@paperclip/adapter-claude-local";
import { execute as codexExecute } from "@paperclip/adapter-codex-local/server";
import { models as codexModels } from "@paperclip/adapter-codex-local";
import { processAdapter } from "./process/index.js"; import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js"; import { httpAdapter } from "./http/index.js";
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
models: claudeModels,
};
const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
execute: codexExecute,
models: codexModels,
};
const adaptersByType = new Map<string, ServerAdapterModule>( const adaptersByType = new Map<string, ServerAdapterModule>(
[claudeLocalAdapter, codexLocalAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]), [claudeLocalAdapter, codexLocalAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
); );

View File

@@ -1,51 +1,12 @@
import type { agents, agentRuntimeState } from "@paperclip/db"; // Re-export all types from the shared adapter-utils package.
// This file is kept as a convenience shim so existing in-tree
export interface UsageSummary { // imports (process/, http/, heartbeat.ts) don't need rewriting.
inputTokens: number; export type {
outputTokens: number; AdapterAgent,
cachedInputTokens?: number; AdapterRuntime,
} UsageSummary,
AdapterExecutionResult,
export interface AdapterExecutionResult { AdapterInvocationMeta,
exitCode: number | null; AdapterExecutionContext,
signal: string | null; ServerAdapterModule,
timedOut: boolean; } from "@paperclip/adapter-utils";
errorMessage?: string | null;
usage?: UsageSummary;
sessionId?: string | null;
provider?: string | null;
model?: string | null;
costUsd?: number | null;
resultJson?: Record<string, unknown> | null;
summary?: string | null;
clearSession?: boolean;
}
export interface AdapterInvocationMeta {
adapterType: string;
command: string;
cwd?: string;
commandArgs?: string[];
env?: Record<string, string>;
prompt?: string;
context?: Record<string, unknown>;
}
export type AgentRecord = typeof agents.$inferSelect;
export type AgentRuntimeStateRecord = typeof agentRuntimeState.$inferSelect;
export interface AdapterExecutionContext {
runId: string;
agent: AgentRecord;
runtime: AgentRuntimeStateRecord;
config: Record<string, unknown>;
context: Record<string, unknown>;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
models?: { id: string; label: string }[];
}

View File

@@ -1,169 +1,32 @@
import { spawn, type ChildProcess } from "node:child_process"; // Re-export everything from the shared adapter-utils/server-utils package.
import { constants as fsConstants, promises as fs } from "node:fs"; // This file is kept as a convenience shim so existing in-tree
import path from "node:path"; // imports (process/, http/, heartbeat.ts) don't need rewriting.
import { logger } from "../middleware/logger.js"; import { logger } from "../middleware/logger.js";
export {
type RunProcessResult,
runningProcesses,
MAX_CAPTURE_BYTES,
MAX_EXCERPT_BYTES,
parseObject,
asString,
asNumber,
asBoolean,
asStringArray,
parseJson,
appendWithCap,
resolvePathValue,
renderTemplate,
redactEnvForLogs,
buildPaperclipEnv,
defaultPathForPlatform,
ensurePathInEnv,
ensureAbsoluteDirectory,
ensureCommandResolvable,
} from "@paperclip/adapter-utils/server-utils";
export interface RunProcessResult { // Re-export runChildProcess with the server's pino logger wired in.
exitCode: number | null; import { runChildProcess as _runChildProcess } from "@paperclip/adapter-utils/server-utils";
signal: string | null; import type { RunProcessResult } from "@paperclip/adapter-utils/server-utils";
timedOut: boolean;
stdout: string;
stderr: string;
}
interface RunningProcess {
child: ChildProcess;
graceSec: number;
}
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;
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 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 vars: Record<string, string> = {
PAPERCLIP_AGENT_ID: agent.id,
PAPERCLIP_COMPANY_ID: agent.companyId,
};
const apiUrl = process.env.PAPERCLIP_API_URL ?? `http://localhost:${process.env.PORT ?? 3100}`;
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";
}
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) {
if (!path.isAbsolute(cwd)) {
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
}
let stats;
try {
stats = await fs.stat(cwd);
} catch {
throw new Error(`Working directory does not exist: "${cwd}"`);
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: "${cwd}"`);
}
}
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
const hasPathSeparator = command.includes("/") || command.includes("\\");
if (hasPathSeparator) {
const absolute = path.isAbsolute(command) ? command : path.resolve(cwd, command);
try {
await fs.access(absolute, fsConstants.X_OK);
} catch {
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
}
return;
}
const pathValue = env.PATH ?? env.Path ?? "";
const delimiter = process.platform === "win32" ? ";" : ":";
const dirs = pathValue.split(delimiter).filter(Boolean);
const windowsExt = process.platform === "win32"
? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
: [""];
for (const dir of dirs) {
for (const ext of windowsExt) {
const candidate = path.join(dir, process.platform === "win32" ? `${command}${ext}` : command);
try {
await fs.access(candidate, fsConstants.X_OK);
return;
} catch {
// continue scanning PATH
}
}
}
throw new Error(`Command not found in PATH: "${command}"`);
}
export async function runChildProcess( export async function runChildProcess(
runId: string, runId: string,
@@ -177,72 +40,8 @@ export async function runChildProcess(
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
}, },
): Promise<RunProcessResult> { ): Promise<RunProcessResult> {
return new Promise<RunProcessResult>((resolve, reject) => { return _runChildProcess(runId, command, args, {
const mergedEnv = ensurePathInEnv({ ...process.env, ...opts.env }); ...opts,
const child = spawn(command, args, { onLogError: (err, id, msg) => logger.warn({ err, runId: id }, msg),
cwd: opts.cwd,
env: mergedEnv,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
let timedOut = false;
let stdout = "";
let stderr = "";
let logChain: Promise<void> = Promise.resolve();
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, Math.max(1, opts.graceSec) * 1000);
}, Math.max(1, opts.timeoutSec) * 1000);
child.stdout?.on("data", (chunk) => {
const text = String(chunk);
stdout = appendWithCap(stdout, text);
logChain = logChain
.then(() => opts.onLog("stdout", text))
.catch((err) => logger.warn({ err, runId }, "failed to append stdout log chunk"));
});
child.stderr?.on("data", (chunk) => {
const text = String(chunk);
stderr = appendWithCap(stderr, text);
logChain = logChain
.then(() => opts.onLog("stderr", text))
.catch((err) => logger.warn({ err, runId }, "failed to append stderr log chunk"));
});
child.on("error", (err) => {
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, signal) => {
clearTimeout(timeout);
runningProcesses.delete(runId);
void logChain.finally(() => {
resolve({
exitCode: code,
signal,
timedOut,
stdout,
stderr,
});
});
});
}); });
} }

View File

@@ -10,6 +10,9 @@
"typecheck": "tsc -b" "typecheck": "tsc -b"
}, },
"dependencies": { "dependencies": {
"@paperclip/adapter-claude-local": "workspace:*",
"@paperclip/adapter-codex-local": "workspace:*",
"@paperclip/adapter-utils": "workspace:*",
"@paperclip/shared": "workspace:*", "@paperclip/shared": "workspace:*",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",

View File

@@ -1,7 +1,7 @@
import type { UIAdapterModule } from "../types"; import type { UIAdapterModule } from "../types";
import { parseClaudeStdoutLine } from "./parse-stdout"; import { parseClaudeStdoutLine } from "@paperclip/adapter-claude-local/ui";
import { ClaudeLocalConfigFields } from "./config-fields"; import { ClaudeLocalConfigFields } from "./config-fields";
import { buildClaudeLocalConfig } from "./build-config"; import { buildClaudeLocalConfig } from "@paperclip/adapter-claude-local/ui";
export const claudeLocalUIAdapter: UIAdapterModule = { export const claudeLocalUIAdapter: UIAdapterModule = {
type: "claude_local", type: "claude_local",

View File

@@ -1,7 +1,7 @@
import type { UIAdapterModule } from "../types"; import type { UIAdapterModule } from "../types";
import { parseCodexStdoutLine } from "./parse-stdout"; import { parseCodexStdoutLine } from "@paperclip/adapter-codex-local/ui";
import { CodexLocalConfigFields } from "./config-fields"; import { CodexLocalConfigFields } from "./config-fields";
import { buildCodexLocalConfig } from "./build-config"; import { buildCodexLocalConfig } from "@paperclip/adapter-codex-local/ui";
export const codexLocalUIAdapter: UIAdapterModule = { export const codexLocalUIAdapter: UIAdapterModule = {
type: "codex_local", type: "codex_local",

View File

@@ -1,16 +1,8 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import type { CreateConfigValues } from "../components/AgentConfigForm"; import type { CreateConfigValues } from "@paperclip/adapter-utils";
export type TranscriptEntry = // Re-export shared types so local consumers don't need to change imports
| { kind: "assistant"; ts: string; text: string } export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclip/adapter-utils";
| { kind: "tool_call"; ts: string; name: string; input: unknown }
| { 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[];
export interface AdapterConfigFieldsProps { export interface AdapterConfigFieldsProps {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -33,7 +25,7 @@ export interface AdapterConfigFieldsProps {
export interface UIAdapterModule { export interface UIAdapterModule {
type: string; type: string;
label: string; label: string;
parseStdoutLine: StdoutLineParser; parseStdoutLine: (line: string, ts: string) => import("@paperclip/adapter-utils").TranscriptEntry[];
ConfigFields: ComponentType<AdapterConfigFieldsProps>; ConfigFields: ComponentType<AdapterConfigFieldsProps>;
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>; buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
} }

View File

@@ -29,24 +29,10 @@ import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-field
/* ---- Create mode values ---- */ /* ---- Create mode values ---- */
export interface CreateConfigValues { // Canonical type lives in @paperclip/adapter-utils; re-exported here
adapterType: string; // so existing imports from this file keep working.
cwd: string; export type { CreateConfigValues } from "@paperclip/adapter-utils";
promptTemplate: string; import type { CreateConfigValues } from "@paperclip/adapter-utils";
model: string;
dangerouslySkipPermissions: boolean;
search: boolean;
dangerouslyBypassSandbox: boolean;
command: string;
args: string;
extraArgs: string;
envVars: string;
url: string;
bootstrapPrompt: string;
maxTurnsPerRun: number;
heartbeatEnabled: boolean;
intervalSec: number;
}
export const defaultCreateValues: CreateConfigValues = { export const defaultCreateValues: CreateConfigValues = {
adapterType: "claude_local", adapterType: "claude_local",