Extract adapter registry across CLI, server, and UI
Refactor monolithic heartbeat service, AgentConfigForm, and CLI heartbeat-run into a proper adapter registry pattern. Each adapter type (process, claude-local, codex-local, http) gets its own module with server-side execution logic, CLI invocation, and UI config form. Significantly reduces file sizes and enables adding new adapters without touching core code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
99
cli/src/adapters/claude-local/format-event.ts
Normal file
99
cli/src/adapters/claude-local/format-event.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import pc from "picocolors";
|
||||||
|
|
||||||
|
function asErrorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const message =
|
||||||
|
(typeof obj.message === "string" && obj.message) ||
|
||||||
|
(typeof obj.error === "string" && obj.error) ||
|
||||||
|
(typeof obj.code === "string" && obj.code) ||
|
||||||
|
"";
|
||||||
|
if (message) return message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(obj);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.log(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||||
|
|
||||||
|
if (type === "system" && parsed.subtype === "init") {
|
||||||
|
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
||||||
|
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
||||||
|
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
const message =
|
||||||
|
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
||||||
|
? (parsed.message as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const blockRaw of content) {
|
||||||
|
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
||||||
|
const block = blockRaw as Record<string, unknown>;
|
||||||
|
const blockType = typeof block.type === "string" ? block.type : "";
|
||||||
|
if (blockType === "text") {
|
||||||
|
const text = typeof block.text === "string" ? block.text : "";
|
||||||
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
|
} else if (blockType === "tool_use") {
|
||||||
|
const name = typeof block.name === "string" ? block.name : "unknown";
|
||||||
|
console.log(pc.yellow(`tool_call: ${name}`));
|
||||||
|
if (block.input !== undefined) {
|
||||||
|
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usage =
|
||||||
|
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||||
|
? (parsed.usage as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const input = Number(usage.input_tokens ?? 0);
|
||||||
|
const output = Number(usage.output_tokens ?? 0);
|
||||||
|
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
||||||
|
const cost = Number(parsed.total_cost_usd ?? 0);
|
||||||
|
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||||
|
const isError = parsed.is_error === true;
|
||||||
|
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
||||||
|
if (resultText) {
|
||||||
|
console.log(pc.green("result:"));
|
||||||
|
console.log(resultText);
|
||||||
|
}
|
||||||
|
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
||||||
|
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
||||||
|
console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
pc.blue(
|
||||||
|
`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.log(pc.gray(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
7
cli/src/adapters/claude-local/index.ts
Normal file
7
cli/src/adapters/claude-local/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { CLIAdapterModule } from "../types.js";
|
||||||
|
import { printClaudeStreamEvent } from "./format-event.js";
|
||||||
|
|
||||||
|
export const claudeLocalCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "claude_local",
|
||||||
|
formatStdoutEvent: printClaudeStreamEvent,
|
||||||
|
};
|
||||||
56
cli/src/adapters/codex-local/format-event.ts
Normal file
56
cli/src/adapters/codex-local/format-event.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import pc from "picocolors";
|
||||||
|
|
||||||
|
export function printCodexStreamEvent(raw: string, _debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
console.log(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||||
|
|
||||||
|
if (type === "thread.started") {
|
||||||
|
const threadId = typeof parsed.thread_id === "string" ? parsed.thread_id : "";
|
||||||
|
console.log(pc.blue(`Codex thread started${threadId ? ` (session: ${threadId})` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "item.completed") {
|
||||||
|
const item =
|
||||||
|
typeof parsed.item === "object" && parsed.item !== null && !Array.isArray(parsed.item)
|
||||||
|
? (parsed.item as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
if (item) {
|
||||||
|
const itemType = typeof item.type === "string" ? item.type : "";
|
||||||
|
if (itemType === "agent_message") {
|
||||||
|
const text = typeof item.text === "string" ? item.text : "";
|
||||||
|
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||||
|
} else if (itemType === "tool_use") {
|
||||||
|
const name = typeof item.name === "string" ? item.name : "unknown";
|
||||||
|
console.log(pc.yellow(`tool_call: ${name}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "turn.completed") {
|
||||||
|
const usage =
|
||||||
|
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||||
|
? (parsed.usage as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const input = Number(usage.input_tokens ?? 0);
|
||||||
|
const output = Number(usage.output_tokens ?? 0);
|
||||||
|
const cached = Number(usage.cached_input_tokens ?? 0);
|
||||||
|
console.log(
|
||||||
|
pc.blue(`tokens: in=${input} out=${output} cached=${cached}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
7
cli/src/adapters/codex-local/index.ts
Normal file
7
cli/src/adapters/codex-local/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { CLIAdapterModule } from "../types.js";
|
||||||
|
import { printCodexStreamEvent } from "./format-event.js";
|
||||||
|
|
||||||
|
export const codexLocalCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "codex_local",
|
||||||
|
formatStdoutEvent: printCodexStreamEvent,
|
||||||
|
};
|
||||||
4
cli/src/adapters/http/format-event.ts
Normal file
4
cli/src/adapters/http/format-event.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function printHttpStdoutEvent(raw: string, _debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line) console.log(line);
|
||||||
|
}
|
||||||
7
cli/src/adapters/http/index.ts
Normal file
7
cli/src/adapters/http/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { CLIAdapterModule } from "../types.js";
|
||||||
|
import { printHttpStdoutEvent } from "./format-event.js";
|
||||||
|
|
||||||
|
export const httpCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "http",
|
||||||
|
formatStdoutEvent: printHttpStdoutEvent,
|
||||||
|
};
|
||||||
2
cli/src/adapters/index.ts
Normal file
2
cli/src/adapters/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { getCLIAdapter } from "./registry.js";
|
||||||
|
export type { CLIAdapterModule } from "./types.js";
|
||||||
4
cli/src/adapters/process/format-event.ts
Normal file
4
cli/src/adapters/process/format-event.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function printProcessStdoutEvent(raw: string, _debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line) console.log(line);
|
||||||
|
}
|
||||||
7
cli/src/adapters/process/index.ts
Normal file
7
cli/src/adapters/process/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { CLIAdapterModule } from "../types.js";
|
||||||
|
import { printProcessStdoutEvent } from "./format-event.js";
|
||||||
|
|
||||||
|
export const processCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "process",
|
||||||
|
formatStdoutEvent: printProcessStdoutEvent,
|
||||||
|
};
|
||||||
13
cli/src/adapters/registry.ts
Normal file
13
cli/src/adapters/registry.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { CLIAdapterModule } from "./types.js";
|
||||||
|
import { claudeLocalCLIAdapter } from "./claude-local/index.js";
|
||||||
|
import { codexLocalCLIAdapter } from "./codex-local/index.js";
|
||||||
|
import { processCLIAdapter } from "./process/index.js";
|
||||||
|
import { httpCLIAdapter } from "./http/index.js";
|
||||||
|
|
||||||
|
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||||
|
[claudeLocalCLIAdapter, codexLocalCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||||
|
return adaptersByType.get(type) ?? processCLIAdapter;
|
||||||
|
}
|
||||||
4
cli/src/adapters/types.ts
Normal file
4
cli/src/adapters/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface CLIAdapterModule {
|
||||||
|
type: string;
|
||||||
|
formatStdoutEvent: (line: string, debug: boolean) => void;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import pc from "picocolors";
|
|||||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@paperclip/shared";
|
import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@paperclip/shared";
|
||||||
import type { PaperclipConfig } from "../config/schema.js";
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
import { readConfig } from "../config/store.js";
|
import { readConfig } from "../config/store.js";
|
||||||
|
import { getCLIAdapter } from "../adapters/index.js";
|
||||||
|
|
||||||
const HEARTBEAT_SOURCES = ["timer", "assignment", "on_demand", "automation"] as const;
|
const HEARTBEAT_SOURCES = ["timer", "assignment", "on_demand", "automation"] as const;
|
||||||
const HEARTBEAT_TRIGGERS = ["manual", "ping", "callback", "system"] as const;
|
const HEARTBEAT_TRIGGERS = ["manual", "ping", "callback", "system"] as const;
|
||||||
@@ -49,6 +50,8 @@ function asErrorText(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AdapterType = string;
|
||||||
|
|
||||||
export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
||||||
const debug = Boolean(opts.debug);
|
const debug = Boolean(opts.debug);
|
||||||
const parsedTimeout = Number.parseInt(opts.timeoutMs, 10);
|
const parsedTimeout = Number.parseInt(opts.timeoutMs, 10);
|
||||||
@@ -154,86 +157,8 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const printClaudeStreamEvent = (raw: string) => {
|
const adapterType: AdapterType = agent.adapterType ?? "claude_local";
|
||||||
const line = raw.trim();
|
const cliAdapter = getCLIAdapter(adapterType);
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
let parsed: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
console.log(line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
||||||
|
|
||||||
if (type === "system" && parsed.subtype === "init") {
|
|
||||||
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
|
||||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
|
||||||
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
const message =
|
|
||||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
|
||||||
? (parsed.message as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const blockRaw of content) {
|
|
||||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
|
||||||
const block = blockRaw as Record<string, unknown>;
|
|
||||||
const blockType = typeof block.type === "string" ? block.type : "";
|
|
||||||
if (blockType === "text") {
|
|
||||||
const text = typeof block.text === "string" ? block.text : "";
|
|
||||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
|
||||||
} else if (blockType === "tool_use") {
|
|
||||||
const name = typeof block.name === "string" ? block.name : "unknown";
|
|
||||||
console.log(pc.yellow(`tool_call: ${name}`));
|
|
||||||
if (block.input !== undefined) {
|
|
||||||
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
const usage =
|
|
||||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
|
||||||
? (parsed.usage as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const input = Number(usage.input_tokens ?? 0);
|
|
||||||
const output = Number(usage.output_tokens ?? 0);
|
|
||||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
|
||||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
|
||||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
|
||||||
const isError = parsed.is_error === true;
|
|
||||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
|
||||||
if (resultText) {
|
|
||||||
console.log(pc.green("result:"));
|
|
||||||
console.log(resultText);
|
|
||||||
}
|
|
||||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
|
||||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
|
||||||
console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
|
|
||||||
if (errors.length > 0) {
|
|
||||||
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
pc.blue(
|
|
||||||
`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log(pc.gray(line));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStreamChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => {
|
const handleStreamChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => {
|
||||||
if (debug) {
|
if (debug) {
|
||||||
@@ -250,7 +175,7 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
|||||||
const lines = combined.split(/\r?\n/);
|
const lines = combined.split(/\r?\n/);
|
||||||
stdoutJsonBuffer = lines.pop() ?? "";
|
stdoutJsonBuffer = lines.pop() ?? "";
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
printClaudeStreamEvent(line);
|
cliAdapter.formatStdoutEvent(line, debug);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,7 +283,7 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
|
|||||||
|
|
||||||
if (finalStatus) {
|
if (finalStatus) {
|
||||||
if (!debug && stdoutJsonBuffer.trim()) {
|
if (!debug && stdoutJsonBuffer.trim()) {
|
||||||
printClaudeStreamEvent(stdoutJsonBuffer);
|
cliAdapter.formatStdoutEvent(stdoutJsonBuffer, debug);
|
||||||
stdoutJsonBuffer = "";
|
stdoutJsonBuffer = "";
|
||||||
}
|
}
|
||||||
const label = `Run ${activeRunId} completed with status ${finalStatus}`;
|
const label = `Run ${activeRunId} completed with status ${finalStatus}`;
|
||||||
|
|||||||
197
server/src/adapters/claude-local/execute.ts
Normal file
197
server/src/adapters/claude-local/execute.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||||
|
import type { RunProcessResult } from "../utils.js";
|
||||||
|
import {
|
||||||
|
asString,
|
||||||
|
asNumber,
|
||||||
|
asBoolean,
|
||||||
|
asStringArray,
|
||||||
|
parseObject,
|
||||||
|
parseJson,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
redactEnvForLogs,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
ensurePathInEnv,
|
||||||
|
renderTemplate,
|
||||||
|
runChildProcess,
|
||||||
|
} from "../utils.js";
|
||||||
|
import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||||
|
|
||||||
|
const promptTemplate = asString(
|
||||||
|
config.promptTemplate,
|
||||||
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
|
);
|
||||||
|
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
||||||
|
const command = asString(config.command, "claude");
|
||||||
|
const model = asString(config.model, "");
|
||||||
|
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||||
|
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||||
|
|
||||||
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
|
await ensureAbsoluteDirectory(cwd);
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
|
if (typeof v === "string") env[k] = v;
|
||||||
|
}
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
||||||
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const sessionId = runtime.sessionId;
|
||||||
|
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
|
const prompt = renderTemplate(template, {
|
||||||
|
company: { id: agent.companyId },
|
||||||
|
agent,
|
||||||
|
run: { id: runId, source: "on_demand" },
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
||||||
|
const args = ["--print", prompt, "--output-format", "stream-json", "--verbose"];
|
||||||
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
|
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||||
|
if (model) args.push("--model", model);
|
||||||
|
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
|
||||||
|
const stderrLine =
|
||||||
|
proc.stderr
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? "";
|
||||||
|
|
||||||
|
if ((proc.exitCode ?? 0) === 0) {
|
||||||
|
return "Failed to parse claude JSON output";
|
||||||
|
}
|
||||||
|
|
||||||
|
return stderrLine
|
||||||
|
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
|
||||||
|
: `Claude exited with code ${proc.exitCode ?? -1}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAttempt = async (resumeSessionId: string | null) => {
|
||||||
|
const args = buildClaudeArgs(resumeSessionId);
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "claude_local",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandArgs: args.map((value, idx) => (idx === 1 ? `<prompt ${prompt.length} chars>` : value)),
|
||||||
|
env: redactEnvForLogs(env),
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
||||||
|
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
||||||
|
return { proc, parsedStream, parsed };
|
||||||
|
};
|
||||||
|
|
||||||
|
const toAdapterResult = (
|
||||||
|
attempt: {
|
||||||
|
proc: RunProcessResult;
|
||||||
|
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
|
||||||
|
parsed: Record<string, unknown> | null;
|
||||||
|
},
|
||||||
|
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
|
||||||
|
): AdapterExecutionResult => {
|
||||||
|
const { proc, parsedStream, parsed } = attempt;
|
||||||
|
if (proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: parseFallbackErrorMessage(proc),
|
||||||
|
resultJson: {
|
||||||
|
stdout: proc.stdout,
|
||||||
|
stderr: proc.stderr,
|
||||||
|
},
|
||||||
|
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage =
|
||||||
|
parsedStream.usage ??
|
||||||
|
(() => {
|
||||||
|
const usageObj = parseObject(parsed.usage);
|
||||||
|
return {
|
||||||
|
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||||
|
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||||
|
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
const resolvedSessionId =
|
||||||
|
parsedStream.sessionId ??
|
||||||
|
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage:
|
||||||
|
(proc.exitCode ?? 0) === 0
|
||||||
|
? null
|
||||||
|
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
||||||
|
usage,
|
||||||
|
sessionId: resolvedSessionId,
|
||||||
|
provider: "anthropic",
|
||||||
|
model: parsedStream.model || asString(parsed.model, model),
|
||||||
|
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||||
|
resultJson: parsed,
|
||||||
|
summary: parsedStream.summary || asString(parsed.result, ""),
|
||||||
|
clearSession: Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = await runAttempt(sessionId ?? null);
|
||||||
|
if (
|
||||||
|
sessionId &&
|
||||||
|
!initial.proc.timedOut &&
|
||||||
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
|
initial.parsed &&
|
||||||
|
isClaudeUnknownSessionError(initial.parsed)
|
||||||
|
) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
|
);
|
||||||
|
const retry = await runAttempt(null);
|
||||||
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return toAdapterResult(initial, { fallbackSessionId: runtime.sessionId });
|
||||||
|
}
|
||||||
12
server/src/adapters/claude-local/index.ts
Normal file
12
server/src/adapters/claude-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
132
server/src/adapters/claude-local/parse.ts
Normal file
132
server/src/adapters/claude-local/parse.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { UsageSummary } from "../types.js";
|
||||||
|
import { asString, asNumber, parseObject, parseJson } from "../utils.js";
|
||||||
|
|
||||||
|
export function parseClaudeStreamJson(stdout: string) {
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
let model = "";
|
||||||
|
let finalResult: Record<string, unknown> | null = null;
|
||||||
|
const assistantTexts: string[] = [];
|
||||||
|
|
||||||
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const event = parseJson(line);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const type = asString(event.type, "");
|
||||||
|
if (type === "system" && asString(event.subtype, "") === "init") {
|
||||||
|
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||||
|
model = asString(event.model, model);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||||
|
const message = parseObject(event.message);
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const entry of content) {
|
||||||
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
||||||
|
const block = entry as Record<string, unknown>;
|
||||||
|
if (asString(block.type, "") === "text") {
|
||||||
|
const text = asString(block.text, "");
|
||||||
|
if (text) assistantTexts.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
finalResult = event;
|
||||||
|
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalResult) {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
costUsd: null as number | null,
|
||||||
|
usage: null as UsageSummary | null,
|
||||||
|
summary: assistantTexts.join("\n\n").trim(),
|
||||||
|
resultJson: null as Record<string, unknown> | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageObj = parseObject(finalResult.usage);
|
||||||
|
const usage: UsageSummary = {
|
||||||
|
inputTokens: asNumber(usageObj.input_tokens, 0),
|
||||||
|
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
||||||
|
outputTokens: asNumber(usageObj.output_tokens, 0),
|
||||||
|
};
|
||||||
|
const costRaw = finalResult.total_cost_usd;
|
||||||
|
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
||||||
|
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
costUsd,
|
||||||
|
usage,
|
||||||
|
summary,
|
||||||
|
resultJson: finalResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
|
||||||
|
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
||||||
|
const messages: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
const msg = entry.trim();
|
||||||
|
if (msg) messages.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = entry as Record<string, unknown>;
|
||||||
|
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
||||||
|
if (msg) {
|
||||||
|
messages.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
messages.push(JSON.stringify(obj));
|
||||||
|
} catch {
|
||||||
|
// skip non-serializable entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
|
||||||
|
const subtype = asString(parsed.subtype, "");
|
||||||
|
const resultText = asString(parsed.result, "").trim();
|
||||||
|
const errors = extractClaudeErrorMessages(parsed);
|
||||||
|
|
||||||
|
let detail = resultText;
|
||||||
|
if (!detail && errors.length > 0) {
|
||||||
|
detail = errors[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = ["Claude run failed"];
|
||||||
|
if (subtype) parts.push(`subtype=${subtype}`);
|
||||||
|
if (detail) parts.push(detail);
|
||||||
|
return parts.length > 1 ? parts.join(": ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
||||||
|
const resultText = asString(parsed.result, "").trim();
|
||||||
|
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
||||||
|
.map((msg) => msg.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return allMessages.some((msg) =>
|
||||||
|
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
||||||
|
);
|
||||||
|
}
|
||||||
117
server/src/adapters/codex-local/execute.ts
Normal file
117
server/src/adapters/codex-local/execute.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||||
|
import {
|
||||||
|
asString,
|
||||||
|
asNumber,
|
||||||
|
asBoolean,
|
||||||
|
asStringArray,
|
||||||
|
parseObject,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
redactEnvForLogs,
|
||||||
|
ensureAbsoluteDirectory,
|
||||||
|
ensureCommandResolvable,
|
||||||
|
ensurePathInEnv,
|
||||||
|
renderTemplate,
|
||||||
|
runChildProcess,
|
||||||
|
} from "../utils.js";
|
||||||
|
import { parseCodexJsonl } from "./parse.js";
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||||
|
|
||||||
|
const promptTemplate = asString(
|
||||||
|
config.promptTemplate,
|
||||||
|
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||||
|
);
|
||||||
|
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
||||||
|
const command = asString(config.command, "codex");
|
||||||
|
const model = asString(config.model, "");
|
||||||
|
const search = asBoolean(config.search, false);
|
||||||
|
const bypass = asBoolean(config.dangerouslyBypassApprovalsAndSandbox, false);
|
||||||
|
|
||||||
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
|
await ensureAbsoluteDirectory(cwd);
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
|
if (typeof v === "string") env[k] = v;
|
||||||
|
}
|
||||||
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||||
|
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||||
|
|
||||||
|
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
||||||
|
const graceSec = asNumber(config.graceSec, 20);
|
||||||
|
const extraArgs = (() => {
|
||||||
|
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||||
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
|
return asStringArray(config.args);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const sessionId = runtime.sessionId;
|
||||||
|
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
|
const prompt = renderTemplate(template, {
|
||||||
|
company: { id: agent.companyId },
|
||||||
|
agent,
|
||||||
|
run: { id: runId, source: "on_demand" },
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = ["exec", "--json"];
|
||||||
|
if (search) args.unshift("--search");
|
||||||
|
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||||
|
if (model) args.push("--model", model);
|
||||||
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
|
if (sessionId) args.push("resume", sessionId, prompt);
|
||||||
|
else args.push(prompt);
|
||||||
|
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "codex_local",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandArgs: args.map((value, idx) => {
|
||||||
|
if (!sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||||
|
if (sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||||
|
return value;
|
||||||
|
}),
|
||||||
|
env: redactEnvForLogs(env),
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseCodexJsonl(proc.stdout);
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: (proc.exitCode ?? 0) === 0 ? null : `Codex exited with code ${proc.exitCode ?? -1}`,
|
||||||
|
usage: parsed.usage,
|
||||||
|
sessionId: parsed.sessionId ?? runtime.sessionId,
|
||||||
|
provider: "openai",
|
||||||
|
model,
|
||||||
|
costUsd: null,
|
||||||
|
resultJson: {
|
||||||
|
stdout: proc.stdout,
|
||||||
|
stderr: proc.stderr,
|
||||||
|
},
|
||||||
|
summary: parsed.summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
server/src/adapters/codex-local/index.ts
Normal file
12
server/src/adapters/codex-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
47
server/src/adapters/codex-local/parse.ts
Normal file
47
server/src/adapters/codex-local/parse.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { asString, asNumber, parseObject, parseJson } from "../utils.js";
|
||||||
|
|
||||||
|
export function parseCodexJsonl(stdout: string) {
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
const messages: string[] = [];
|
||||||
|
const usage = {
|
||||||
|
inputTokens: 0,
|
||||||
|
cachedInputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
const event = parseJson(line);
|
||||||
|
if (!event) continue;
|
||||||
|
|
||||||
|
const type = asString(event.type, "");
|
||||||
|
if (type === "thread.started") {
|
||||||
|
sessionId = asString(event.thread_id, sessionId ?? "") || sessionId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "item.completed") {
|
||||||
|
const item = parseObject(event.item);
|
||||||
|
if (asString(item.type, "") === "agent_message") {
|
||||||
|
const text = asString(item.text, "");
|
||||||
|
if (text) messages.push(text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "turn.completed") {
|
||||||
|
const usageObj = parseObject(event.usage);
|
||||||
|
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
||||||
|
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
||||||
|
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
summary: messages.join("\n\n").trim(),
|
||||||
|
usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
server/src/adapters/http/execute.ts
Normal file
42
server/src/adapters/http/execute.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||||
|
import { asString, asNumber, parseObject } from "../utils.js";
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { config, runId, agent, context } = ctx;
|
||||||
|
const url = asString(config.url, "");
|
||||||
|
if (!url) throw new Error("HTTP adapter missing url");
|
||||||
|
|
||||||
|
const method = asString(config.method, "POST");
|
||||||
|
const timeoutMs = asNumber(config.timeoutMs, 15000);
|
||||||
|
const headers = parseObject(config.headers) as Record<string, string>;
|
||||||
|
const payloadTemplate = parseObject(config.payloadTemplate);
|
||||||
|
const body = { ...payloadTemplate, agentId: agent.id, runId, context };
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP invoke failed with status ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
timedOut: false,
|
||||||
|
summary: `HTTP ${method} ${url}`,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/src/adapters/http/index.ts
Normal file
8
server/src/adapters/http/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ServerAdapterModule } from "../types.js";
|
||||||
|
import { execute } from "./execute.js";
|
||||||
|
|
||||||
|
export const httpAdapter: ServerAdapterModule = {
|
||||||
|
type: "http",
|
||||||
|
execute,
|
||||||
|
models: [],
|
||||||
|
};
|
||||||
11
server/src/adapters/index.ts
Normal file
11
server/src/adapters/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { getServerAdapter, listAdapterModels } from "./registry.js";
|
||||||
|
export type {
|
||||||
|
ServerAdapterModule,
|
||||||
|
AdapterExecutionContext,
|
||||||
|
AdapterExecutionResult,
|
||||||
|
AdapterInvocationMeta,
|
||||||
|
UsageSummary,
|
||||||
|
AgentRecord,
|
||||||
|
AgentRuntimeStateRecord,
|
||||||
|
} from "./types.js";
|
||||||
|
export { runningProcesses } from "./utils.js";
|
||||||
77
server/src/adapters/process/execute.ts
Normal file
77
server/src/adapters/process/execute.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "../types.js";
|
||||||
|
import {
|
||||||
|
asString,
|
||||||
|
asNumber,
|
||||||
|
asStringArray,
|
||||||
|
parseObject,
|
||||||
|
buildPaperclipEnv,
|
||||||
|
redactEnvForLogs,
|
||||||
|
runChildProcess,
|
||||||
|
} from "../utils.js";
|
||||||
|
|
||||||
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
|
const { runId, agent, config, onLog, onMeta } = ctx;
|
||||||
|
const command = asString(config.command, "");
|
||||||
|
if (!command) throw new Error("Process adapter missing command");
|
||||||
|
|
||||||
|
const args = asStringArray(config.args);
|
||||||
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
|
const envConfig = parseObject(config.env);
|
||||||
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||||
|
for (const [k, v] of Object.entries(envConfig)) {
|
||||||
|
if (typeof v === "string") env[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSec = asNumber(config.timeoutSec, 900);
|
||||||
|
const graceSec = asNumber(config.graceSec, 15);
|
||||||
|
|
||||||
|
if (onMeta) {
|
||||||
|
await onMeta({
|
||||||
|
adapterType: "process",
|
||||||
|
command,
|
||||||
|
cwd,
|
||||||
|
commandArgs: args,
|
||||||
|
env: redactEnvForLogs(env),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
timeoutSec,
|
||||||
|
graceSec,
|
||||||
|
onLog,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (proc.timedOut) {
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: true,
|
||||||
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((proc.exitCode ?? 0) !== 0) {
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
errorMessage: `Process exited with code ${proc.exitCode ?? -1}`,
|
||||||
|
resultJson: {
|
||||||
|
stdout: proc.stdout,
|
||||||
|
stderr: proc.stderr,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: proc.exitCode,
|
||||||
|
signal: proc.signal,
|
||||||
|
timedOut: false,
|
||||||
|
resultJson: {
|
||||||
|
stdout: proc.stdout,
|
||||||
|
stderr: proc.stderr,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
8
server/src/adapters/process/index.ts
Normal file
8
server/src/adapters/process/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ServerAdapterModule } from "../types.js";
|
||||||
|
import { execute } from "./execute.js";
|
||||||
|
|
||||||
|
export const processAdapter: ServerAdapterModule = {
|
||||||
|
type: "process",
|
||||||
|
execute,
|
||||||
|
models: [],
|
||||||
|
};
|
||||||
22
server/src/adapters/registry.ts
Normal file
22
server/src/adapters/registry.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ServerAdapterModule } from "./types.js";
|
||||||
|
import { claudeLocalAdapter } from "./claude-local/index.js";
|
||||||
|
import { codexLocalAdapter } from "./codex-local/index.js";
|
||||||
|
import { processAdapter } from "./process/index.js";
|
||||||
|
import { httpAdapter } from "./http/index.js";
|
||||||
|
|
||||||
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
|
[claudeLocalAdapter, codexLocalAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||||
|
const adapter = adaptersByType.get(type);
|
||||||
|
if (!adapter) {
|
||||||
|
// Fall back to process adapter for unknown types
|
||||||
|
return processAdapter;
|
||||||
|
}
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdapterModels(type: string): { id: string; label: string }[] {
|
||||||
|
return adaptersByType.get(type)?.models ?? [];
|
||||||
|
}
|
||||||
51
server/src/adapters/types.ts
Normal file
51
server/src/adapters/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { agents, agentRuntimeState } from "@paperclip/db";
|
||||||
|
|
||||||
|
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 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 }[];
|
||||||
|
}
|
||||||
248
server/src/adapters/utils.ts
Normal file
248
server/src/adapters/utils.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { logger } from "../middleware/logger.js";
|
||||||
|
|
||||||
|
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>;
|
||||||
|
},
|
||||||
|
): Promise<RunProcessResult> {
|
||||||
|
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) => 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,31 +9,16 @@ import {
|
|||||||
import { validate } from "../middleware/validate.js";
|
import { validate } from "../middleware/validate.js";
|
||||||
import { agentService, heartbeatService, logActivity } from "../services/index.js";
|
import { agentService, heartbeatService, logActivity } from "../services/index.js";
|
||||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { listAdapterModels } from "../adapters/index.js";
|
||||||
|
|
||||||
export function agentRoutes(db: Db) {
|
export function agentRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = agentService(db);
|
const svc = agentService(db);
|
||||||
const heartbeat = heartbeatService(db);
|
const heartbeat = heartbeatService(db);
|
||||||
|
|
||||||
// Static model lists for adapters — can be extended to query CLIs dynamically
|
|
||||||
const adapterModels: Record<string, { id: string; label: string }[]> = {
|
|
||||||
claude_local: [
|
|
||||||
{ 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" },
|
|
||||||
],
|
|
||||||
codex_local: [
|
|
||||||
{ id: "o4-mini", label: "o4-mini" },
|
|
||||||
{ id: "o3", label: "o3" },
|
|
||||||
{ id: "codex-mini-latest", label: "Codex Mini" },
|
|
||||||
],
|
|
||||||
process: [],
|
|
||||||
http: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
router.get("/adapters/:type/models", (req, res) => {
|
router.get("/adapters/:type/models", (req, res) => {
|
||||||
const type = req.params.type as string;
|
const type = req.params.type as string;
|
||||||
const models = adapterModels[type] ?? [];
|
const models = listAdapterModels(type);
|
||||||
res.json(models);
|
res.json(models);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
|
||||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclip/db";
|
import type { Db } from "@paperclip/db";
|
||||||
import {
|
import {
|
||||||
@@ -15,49 +12,14 @@ import { conflict, notFound } from "../errors.js";
|
|||||||
import { logger } from "../middleware/logger.js";
|
import { logger } from "../middleware/logger.js";
|
||||||
import { publishLiveEvent } from "./live-events.js";
|
import { publishLiveEvent } from "./live-events.js";
|
||||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||||
|
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
||||||
|
import type { AdapterExecutionResult, AdapterInvocationMeta } from "../adapters/index.js";
|
||||||
|
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||||
|
|
||||||
interface RunningProcess {
|
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
child: ChildProcess;
|
|
||||||
graceSec: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RunProcessResult {
|
function appendExcerpt(prev: string, chunk: string) {
|
||||||
exitCode: number | null;
|
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
||||||
signal: string | null;
|
|
||||||
timedOut: boolean;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageSummary {
|
|
||||||
inputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
cachedInputTokens?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AdapterInvocationMeta {
|
|
||||||
adapterType: string;
|
|
||||||
command: string;
|
|
||||||
cwd?: string;
|
|
||||||
commandArgs?: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
prompt?: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WakeupOptions {
|
interface WakeupOptions {
|
||||||
@@ -71,418 +33,6 @@ interface WakeupOptions {
|
|||||||
contextSnapshot?: Record<string, unknown>;
|
contextSnapshot?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningProcesses = new Map<string, RunningProcess>();
|
|
||||||
const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
||||||
const MAX_EXCERPT_BYTES = 32 * 1024;
|
|
||||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
|
||||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
|
||||||
|
|
||||||
function parseObject(value: unknown): Record<string, unknown> {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asString(value: unknown, fallback: string): string {
|
|
||||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown, fallback: number): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asBoolean(value: unknown, fallback: boolean): boolean {
|
|
||||||
return typeof value === "boolean" ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringArray(value: unknown): string[] {
|
|
||||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJson(value: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
return JSON.parse(value) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
|
|
||||||
const combined = prev + chunk;
|
|
||||||
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendExcerpt(prev: string, chunk: string) {
|
|
||||||
return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTemplate(template: string, data: Record<string, unknown>) {
|
|
||||||
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCodexJsonl(stdout: string) {
|
|
||||||
let sessionId: string | null = null;
|
|
||||||
const messages: string[] = [];
|
|
||||||
const usage = {
|
|
||||||
inputTokens: 0,
|
|
||||||
cachedInputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
const event = parseJson(line);
|
|
||||||
if (!event) continue;
|
|
||||||
|
|
||||||
const type = asString(event.type, "");
|
|
||||||
if (type === "thread.started") {
|
|
||||||
sessionId = asString(event.thread_id, sessionId ?? "") || sessionId;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "item.completed") {
|
|
||||||
const item = parseObject(event.item);
|
|
||||||
if (asString(item.type, "") === "agent_message") {
|
|
||||||
const text = asString(item.text, "");
|
|
||||||
if (text) messages.push(text);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.completed") {
|
|
||||||
const usageObj = parseObject(event.usage);
|
|
||||||
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
|
||||||
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
|
||||||
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
summary: messages.join("\n\n").trim(),
|
|
||||||
usage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
|
|
||||||
const subtype = asString(parsed.subtype, "");
|
|
||||||
const resultText = asString(parsed.result, "").trim();
|
|
||||||
const errors = extractClaudeErrorMessages(parsed);
|
|
||||||
|
|
||||||
let detail = resultText;
|
|
||||||
if (!detail && errors.length > 0) {
|
|
||||||
detail = errors[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = ["Claude run failed"];
|
|
||||||
if (subtype) parts.push(`subtype=${subtype}`);
|
|
||||||
if (detail) parts.push(detail);
|
|
||||||
return parts.length > 1 ? parts.join(": ") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
|
|
||||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
||||||
const messages: string[] = [];
|
|
||||||
|
|
||||||
for (const entry of raw) {
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
const msg = entry.trim();
|
|
||||||
if (msg) messages.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = entry as Record<string, unknown>;
|
|
||||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
|
||||||
if (msg) {
|
|
||||||
messages.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
messages.push(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
// skip non-serializable entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
|
||||||
const resultText = asString(parsed.result, "").trim();
|
|
||||||
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
|
||||||
.map((msg) => msg.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return allMessages.some((msg) =>
|
|
||||||
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseClaudeStreamJson(stdout: string) {
|
|
||||||
let sessionId: string | null = null;
|
|
||||||
let model = "";
|
|
||||||
let finalResult: Record<string, unknown> | null = null;
|
|
||||||
const assistantTexts: string[] = [];
|
|
||||||
|
|
||||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
const event = parseJson(line);
|
|
||||||
if (!event) continue;
|
|
||||||
|
|
||||||
const type = asString(event.type, "");
|
|
||||||
if (type === "system" && asString(event.subtype, "") === "init") {
|
|
||||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
||||||
model = asString(event.model, model);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
||||||
const message = parseObject(event.message);
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const entry of content) {
|
|
||||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
||||||
const block = entry as Record<string, unknown>;
|
|
||||||
if (asString(block.type, "") === "text") {
|
|
||||||
const text = asString(block.text, "");
|
|
||||||
if (text) assistantTexts.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
finalResult = event;
|
|
||||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalResult) {
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
costUsd: null as number | null,
|
|
||||||
usage: null as UsageSummary | null,
|
|
||||||
summary: assistantTexts.join("\n\n").trim(),
|
|
||||||
resultJson: null as Record<string, unknown> | null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const usageObj = parseObject(finalResult.usage);
|
|
||||||
const usage: UsageSummary = {
|
|
||||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
|
||||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
|
||||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
|
||||||
};
|
|
||||||
const costRaw = finalResult.total_cost_usd;
|
|
||||||
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
|
||||||
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
costUsd,
|
|
||||||
usage,
|
|
||||||
summary,
|
|
||||||
resultJson: finalResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>;
|
|
||||||
},
|
|
||||||
): Promise<RunProcessResult> {
|
|
||||||
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) => 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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() };
|
|
||||||
}
|
|
||||||
|
|
||||||
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}"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 function heartbeatService(db: Db) {
|
export function heartbeatService(db: Db) {
|
||||||
const runLogStore = getRunLogStore();
|
const runLogStore = getRunLogStore();
|
||||||
|
|
||||||
@@ -717,414 +267,6 @@ export function heartbeatService(db: Db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeHttpRun(
|
|
||||||
runId: string,
|
|
||||||
agentId: string,
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
context: Record<string, unknown>,
|
|
||||||
): Promise<AdapterExecutionResult> {
|
|
||||||
const url = asString(config.url, "");
|
|
||||||
if (!url) throw new Error("HTTP adapter missing url");
|
|
||||||
|
|
||||||
const method = asString(config.method, "POST");
|
|
||||||
const timeoutMs = asNumber(config.timeoutMs, 15000);
|
|
||||||
const headers = parseObject(config.headers) as Record<string, string>;
|
|
||||||
const payloadTemplate = parseObject(config.payloadTemplate);
|
|
||||||
const body = { ...payloadTemplate, agentId, runId, context };
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP invoke failed with status ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
timedOut: false,
|
|
||||||
summary: `HTTP ${method} ${url}`,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeProcessRun(
|
|
||||||
runId: string,
|
|
||||||
agent: typeof agents.$inferSelect,
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>,
|
|
||||||
): Promise<AdapterExecutionResult> {
|
|
||||||
const command = asString(config.command, "");
|
|
||||||
if (!command) throw new Error("Process adapter missing command");
|
|
||||||
|
|
||||||
const args = asStringArray(config.args);
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
|
||||||
const envConfig = parseObject(config.env);
|
|
||||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
|
||||||
if (typeof v === "string") env[k] = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 900);
|
|
||||||
const graceSec = asNumber(config.graceSec, 15);
|
|
||||||
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "process",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandArgs: args,
|
|
||||||
env: redactEnvForLogs(env),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onLog,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (proc.timedOut) {
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((proc.exitCode ?? 0) !== 0) {
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage: `Process exited with code ${proc.exitCode ?? -1}`,
|
|
||||||
resultJson: {
|
|
||||||
stdout: proc.stdout,
|
|
||||||
stderr: proc.stderr,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
resultJson: {
|
|
||||||
stdout: proc.stdout,
|
|
||||||
stderr: proc.stderr,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeClaudeLocalRun(
|
|
||||||
runId: string,
|
|
||||||
agent: typeof agents.$inferSelect,
|
|
||||||
runtime: typeof agentRuntimeState.$inferSelect,
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
context: Record<string, unknown>,
|
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>,
|
|
||||||
): Promise<AdapterExecutionResult> {
|
|
||||||
const promptTemplate = asString(
|
|
||||||
config.promptTemplate,
|
|
||||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
|
||||||
);
|
|
||||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
|
||||||
const command = asString(config.command, "claude");
|
|
||||||
const model = asString(config.model, "");
|
|
||||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
|
||||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
|
||||||
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
|
||||||
await ensureAbsoluteDirectory(cwd);
|
|
||||||
const envConfig = parseObject(config.env);
|
|
||||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
|
||||||
if (typeof v === "string") env[k] = v;
|
|
||||||
}
|
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const sessionId = runtime.sessionId;
|
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
|
||||||
const prompt = renderTemplate(template, {
|
|
||||||
company: { id: agent.companyId },
|
|
||||||
agent,
|
|
||||||
run: { id: runId, source: "on_demand" },
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
|
||||||
const args = ["--print", prompt, "--output-format", "stream-json", "--verbose"];
|
|
||||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
|
||||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
|
||||||
if (model) args.push("--model", model);
|
|
||||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
return args;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
|
|
||||||
const stderrLine =
|
|
||||||
proc.stderr
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find(Boolean) ?? "";
|
|
||||||
|
|
||||||
if ((proc.exitCode ?? 0) === 0) {
|
|
||||||
return "Failed to parse claude JSON output";
|
|
||||||
}
|
|
||||||
|
|
||||||
return stderrLine
|
|
||||||
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
|
|
||||||
: `Claude exited with code ${proc.exitCode ?? -1}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAttempt = async (resumeSessionId: string | null) => {
|
|
||||||
const args = buildClaudeArgs(resumeSessionId);
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "claude_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandArgs: args.map((value, idx) => (idx === 1 ? `<prompt ${prompt.length} chars>` : value)),
|
|
||||||
env: redactEnvForLogs(env),
|
|
||||||
prompt,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onLog,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
|
||||||
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
|
||||||
return { proc, parsedStream, parsed };
|
|
||||||
};
|
|
||||||
|
|
||||||
const toAdapterResult = (
|
|
||||||
attempt: {
|
|
||||||
proc: RunProcessResult;
|
|
||||||
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
|
|
||||||
parsed: Record<string, unknown> | null;
|
|
||||||
},
|
|
||||||
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
|
|
||||||
): AdapterExecutionResult => {
|
|
||||||
const { proc, parsedStream, parsed } = attempt;
|
|
||||||
if (proc.timedOut) {
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
||||||
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage: parseFallbackErrorMessage(proc),
|
|
||||||
resultJson: {
|
|
||||||
stdout: proc.stdout,
|
|
||||||
stderr: proc.stderr,
|
|
||||||
},
|
|
||||||
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const usage =
|
|
||||||
parsedStream.usage ??
|
|
||||||
(() => {
|
|
||||||
const usageObj = parseObject(parsed.usage);
|
|
||||||
return {
|
|
||||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
|
||||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
|
||||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
const resolvedSessionId =
|
|
||||||
parsedStream.sessionId ??
|
|
||||||
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage:
|
|
||||||
(proc.exitCode ?? 0) === 0
|
|
||||||
? null
|
|
||||||
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
|
||||||
usage,
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
provider: "anthropic",
|
|
||||||
model: parsedStream.model || asString(parsed.model, model),
|
|
||||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
|
||||||
resultJson: parsed,
|
|
||||||
summary: parsedStream.summary || asString(parsed.result, ""),
|
|
||||||
clearSession: Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId ?? null);
|
|
||||||
if (
|
|
||||||
sessionId &&
|
|
||||||
!initial.proc.timedOut &&
|
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
||||||
initial.parsed &&
|
|
||||||
isClaudeUnknownSessionError(initial.parsed)
|
|
||||||
) {
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
||||||
);
|
|
||||||
const retry = await runAttempt(null);
|
|
||||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return toAdapterResult(initial, { fallbackSessionId: runtime.sessionId });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeCodexLocalRun(
|
|
||||||
runId: string,
|
|
||||||
agent: typeof agents.$inferSelect,
|
|
||||||
runtime: typeof agentRuntimeState.$inferSelect,
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
context: Record<string, unknown>,
|
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>,
|
|
||||||
): Promise<AdapterExecutionResult> {
|
|
||||||
const promptTemplate = asString(
|
|
||||||
config.promptTemplate,
|
|
||||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
|
||||||
);
|
|
||||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
|
||||||
const command = asString(config.command, "codex");
|
|
||||||
const model = asString(config.model, "");
|
|
||||||
const search = asBoolean(config.search, false);
|
|
||||||
const bypass = asBoolean(config.dangerouslyBypassApprovalsAndSandbox, false);
|
|
||||||
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
|
||||||
await ensureAbsoluteDirectory(cwd);
|
|
||||||
const envConfig = parseObject(config.env);
|
|
||||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
|
||||||
for (const [k, v] of Object.entries(envConfig)) {
|
|
||||||
if (typeof v === "string") env[k] = v;
|
|
||||||
}
|
|
||||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const sessionId = runtime.sessionId;
|
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
|
||||||
const prompt = renderTemplate(template, {
|
|
||||||
company: { id: agent.companyId },
|
|
||||||
agent,
|
|
||||||
run: { id: runId, source: "on_demand" },
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
|
|
||||||
const args = ["exec", "--json"];
|
|
||||||
if (search) args.unshift("--search");
|
|
||||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
||||||
if (model) args.push("--model", model);
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
if (sessionId) args.push("resume", sessionId, prompt);
|
|
||||||
else args.push(prompt);
|
|
||||||
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "codex_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandArgs: args.map((value, idx) => {
|
|
||||||
if (!sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
|
||||||
if (sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
|
||||||
return value;
|
|
||||||
}),
|
|
||||||
env: redactEnvForLogs(env),
|
|
||||||
prompt,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onLog,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (proc.timedOut) {
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseCodexJsonl(proc.stdout);
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: proc.exitCode,
|
|
||||||
signal: proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage: (proc.exitCode ?? 0) === 0 ? null : `Codex exited with code ${proc.exitCode ?? -1}`,
|
|
||||||
usage: parsed.usage,
|
|
||||||
sessionId: parsed.sessionId ?? runtime.sessionId,
|
|
||||||
provider: "openai",
|
|
||||||
model,
|
|
||||||
costUsd: null,
|
|
||||||
resultJson: {
|
|
||||||
stdout: proc.stdout,
|
|
||||||
stderr: proc.stderr,
|
|
||||||
},
|
|
||||||
summary: parsed.summary,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeRun(runId: string) {
|
async function executeRun(runId: string) {
|
||||||
const run = await getRun(runId);
|
const run = await getRun(runId);
|
||||||
if (!run) return;
|
if (!run) return;
|
||||||
@@ -1238,20 +380,20 @@ export function heartbeatService(db: Db) {
|
|||||||
stream: "system",
|
stream: "system",
|
||||||
level: "info",
|
level: "info",
|
||||||
message: "adapter invocation",
|
message: "adapter invocation",
|
||||||
payload: meta as Record<string, unknown>,
|
payload: meta as unknown as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let adapterResult: AdapterExecutionResult;
|
const adapter = getServerAdapter(agent.adapterType);
|
||||||
if (agent.adapterType === "http") {
|
const adapterResult = await adapter.execute({
|
||||||
adapterResult = await executeHttpRun(run.id, agent.id, config, context);
|
runId: run.id,
|
||||||
} else if (agent.adapterType === "claude_local") {
|
agent,
|
||||||
adapterResult = await executeClaudeLocalRun(run.id, agent, runtime, config, context, onLog, onAdapterMeta);
|
runtime,
|
||||||
} else if (agent.adapterType === "codex_local") {
|
config,
|
||||||
adapterResult = await executeCodexLocalRun(run.id, agent, runtime, config, context, onLog, onAdapterMeta);
|
context,
|
||||||
} else {
|
onLog,
|
||||||
adapterResult = await executeProcessRun(run.id, agent, config, onLog, onAdapterMeta);
|
onMeta: onAdapterMeta,
|
||||||
}
|
});
|
||||||
|
|
||||||
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
let outcome: "succeeded" | "failed" | "cancelled" | "timed_out";
|
||||||
const latestRun = await getRun(run.id);
|
const latestRun = await getRun(run.id);
|
||||||
@@ -1473,9 +615,9 @@ export function heartbeatService(db: Db) {
|
|||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
|
|
||||||
const runtime = await getRuntimeState(agent.id);
|
const runtimeForRun = await getRuntimeState(agent.id);
|
||||||
|
|
||||||
const run = await db
|
const newRun = await db
|
||||||
.insert(heartbeatRuns)
|
.insert(heartbeatRuns)
|
||||||
.values({
|
.values({
|
||||||
companyId: agent.companyId,
|
companyId: agent.companyId,
|
||||||
@@ -1485,7 +627,7 @@ export function heartbeatService(db: Db) {
|
|||||||
status: "queued",
|
status: "queued",
|
||||||
wakeupRequestId: wakeupRequest.id,
|
wakeupRequestId: wakeupRequest.id,
|
||||||
contextSnapshot,
|
contextSnapshot,
|
||||||
sessionIdBefore: runtime?.sessionId ?? null,
|
sessionIdBefore: runtimeForRun?.sessionId ?? null,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => rows[0]);
|
||||||
@@ -1493,28 +635,28 @@ export function heartbeatService(db: Db) {
|
|||||||
await db
|
await db
|
||||||
.update(agentWakeupRequests)
|
.update(agentWakeupRequests)
|
||||||
.set({
|
.set({
|
||||||
runId: run.id,
|
runId: newRun.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
|
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
|
||||||
|
|
||||||
publishLiveEvent({
|
publishLiveEvent({
|
||||||
companyId: run.companyId,
|
companyId: newRun.companyId,
|
||||||
type: "heartbeat.run.queued",
|
type: "heartbeat.run.queued",
|
||||||
payload: {
|
payload: {
|
||||||
runId: run.id,
|
runId: newRun.id,
|
||||||
agentId: run.agentId,
|
agentId: newRun.agentId,
|
||||||
invocationSource: run.invocationSource,
|
invocationSource: newRun.invocationSource,
|
||||||
triggerDetail: run.triggerDetail,
|
triggerDetail: newRun.triggerDetail,
|
||||||
wakeupRequestId: run.wakeupRequestId,
|
wakeupRequestId: newRun.wakeupRequestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
void executeRun(run.id).catch((err) => {
|
void executeRun(newRun.id).catch((err) => {
|
||||||
logger.error({ err, runId: run.id }, "heartbeat execution failed");
|
logger.error({ err, runId: newRun.id }, "heartbeat execution failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
return run;
|
return newRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
40
ui/src/adapters/claude-local/build-config.ts
Normal file
40
ui/src/adapters/claude-local/build-config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
|
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||||
|
if (v.model) ac.model = v.model;
|
||||||
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 15;
|
||||||
|
const env = parseEnvVars(v.envVars);
|
||||||
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
|
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||||
|
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
78
ui/src/adapters/claude-local/config-fields.tsx
Normal file
78
ui/src/adapters/claude-local/config-fields.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
ToggleField,
|
||||||
|
DraftInput,
|
||||||
|
DraftNumberInput,
|
||||||
|
help,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
export function ClaudeLocalConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToggleField
|
||||||
|
label="Skip permissions"
|
||||||
|
hint={help.dangerouslySkipPermissions}
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? values!.dangerouslySkipPermissions
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"dangerouslySkipPermissions",
|
||||||
|
config.dangerouslySkipPermissions !== false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ dangerouslySkipPermissions: v })
|
||||||
|
: mark("adapterConfig", "dangerouslySkipPermissions", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Max turns — only shown in advanced section context, rendered here for availability */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClaudeLocalAdvancedFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
||||||
|
{isCreate ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={inputClass}
|
||||||
|
value={values!.maxTurnsPerRun}
|
||||||
|
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DraftNumberInput
|
||||||
|
value={eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"maxTurnsPerRun",
|
||||||
|
Number(config.maxTurnsPerRun ?? 80),
|
||||||
|
)}
|
||||||
|
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/claude-local/index.ts
Normal file
12
ui/src/adapters/claude-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseClaudeStdoutLine } from "./parse-stdout";
|
||||||
|
import { ClaudeLocalConfigFields } from "./config-fields";
|
||||||
|
import { buildClaudeLocalConfig } from "./build-config";
|
||||||
|
|
||||||
|
export const claudeLocalUIAdapter: UIAdapterModule = {
|
||||||
|
type: "claude_local",
|
||||||
|
label: "Claude Code (local)",
|
||||||
|
parseStdoutLine: parseClaudeStdoutLine,
|
||||||
|
ConfigFields: ClaudeLocalConfigFields,
|
||||||
|
buildAdapterConfig: buildClaudeLocalConfig,
|
||||||
|
};
|
||||||
103
ui/src/adapters/claude-local/parse-stdout.ts
Normal file
103
ui/src/adapters/claude-local/parse-stdout.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { TranscriptEntry } from "../types";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
const rec = asRecord(value);
|
||||||
|
if (!rec) return "";
|
||||||
|
const msg =
|
||||||
|
(typeof rec.message === "string" && rec.message) ||
|
||||||
|
(typeof rec.error === "string" && rec.error) ||
|
||||||
|
(typeof rec.code === "string" && rec.code) ||
|
||||||
|
"";
|
||||||
|
if (msg) return msg;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(rec);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
|
if (!parsed) {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||||
|
if (type === "system" && parsed.subtype === "init") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: "init",
|
||||||
|
ts,
|
||||||
|
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
||||||
|
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "assistant") {
|
||||||
|
const message = asRecord(parsed.message) ?? {};
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
for (const blockRaw of content) {
|
||||||
|
const block = asRecord(blockRaw);
|
||||||
|
if (!block) continue;
|
||||||
|
const blockType = typeof block.type === "string" ? block.type : "";
|
||||||
|
if (blockType === "text") {
|
||||||
|
const text = typeof block.text === "string" ? block.text : "";
|
||||||
|
if (text) entries.push({ kind: "assistant", ts, text });
|
||||||
|
} else if (blockType === "tool_use") {
|
||||||
|
entries.push({
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: typeof block.name === "string" ? block.name : "unknown",
|
||||||
|
input: block.input ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "result") {
|
||||||
|
const usage = asRecord(parsed.usage) ?? {};
|
||||||
|
const inputTokens = asNumber(usage.input_tokens);
|
||||||
|
const outputTokens = asNumber(usage.output_tokens);
|
||||||
|
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
||||||
|
const costUsd = asNumber(parsed.total_cost_usd);
|
||||||
|
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||||
|
const isError = parsed.is_error === true;
|
||||||
|
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||||
|
const text = typeof parsed.result === "string" ? parsed.result : "";
|
||||||
|
return [{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cachedTokens,
|
||||||
|
costUsd,
|
||||||
|
subtype,
|
||||||
|
isError,
|
||||||
|
errors,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
40
ui/src/adapters/codex-local/build-config.ts
Normal file
40
ui/src/adapters/codex-local/build-config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvVars(text: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {};
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eq = trimmed.indexOf("=");
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1);
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||||
|
env[key] = value;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
|
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||||
|
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||||
|
if (v.model) ac.model = v.model;
|
||||||
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 15;
|
||||||
|
const env = parseEnvVars(v.envVars);
|
||||||
|
if (Object.keys(env).length > 0) ac.env = env;
|
||||||
|
ac.search = v.search;
|
||||||
|
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
51
ui/src/adapters/codex-local/config-fields.tsx
Normal file
51
ui/src/adapters/codex-local/config-fields.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
ToggleField,
|
||||||
|
help,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
|
||||||
|
export function CodexLocalConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToggleField
|
||||||
|
label="Bypass sandbox"
|
||||||
|
hint={help.dangerouslyBypassSandbox}
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? values!.dangerouslyBypassSandbox
|
||||||
|
: eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"dangerouslyBypassApprovalsAndSandbox",
|
||||||
|
config.dangerouslyBypassApprovalsAndSandbox !== false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ dangerouslyBypassSandbox: v })
|
||||||
|
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ToggleField
|
||||||
|
label="Enable search"
|
||||||
|
hint={help.search}
|
||||||
|
checked={
|
||||||
|
isCreate
|
||||||
|
? values!.search
|
||||||
|
: eff("adapterConfig", "search", !!config.search)
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ search: v })
|
||||||
|
: mark("adapterConfig", "search", v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/codex-local/index.ts
Normal file
12
ui/src/adapters/codex-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseCodexStdoutLine } from "./parse-stdout";
|
||||||
|
import { CodexLocalConfigFields } from "./config-fields";
|
||||||
|
import { buildCodexLocalConfig } from "./build-config";
|
||||||
|
|
||||||
|
export const codexLocalUIAdapter: UIAdapterModule = {
|
||||||
|
type: "codex_local",
|
||||||
|
label: "Codex (local)",
|
||||||
|
parseStdoutLine: parseCodexStdoutLine,
|
||||||
|
ConfigFields: CodexLocalConfigFields,
|
||||||
|
buildAdapterConfig: buildCodexLocalConfig,
|
||||||
|
};
|
||||||
73
ui/src/adapters/codex-local/parse-stdout.ts
Normal file
73
ui/src/adapters/codex-local/parse-stdout.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { TranscriptEntry } from "../types";
|
||||||
|
|
||||||
|
function safeJsonParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
|
if (!parsed) {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||||
|
|
||||||
|
if (type === "thread.started") {
|
||||||
|
const threadId = typeof parsed.thread_id === "string" ? parsed.thread_id : "";
|
||||||
|
return [{
|
||||||
|
kind: "init",
|
||||||
|
ts,
|
||||||
|
model: "codex",
|
||||||
|
sessionId: threadId,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "item.completed") {
|
||||||
|
const item = asRecord(parsed.item);
|
||||||
|
if (item) {
|
||||||
|
const itemType = typeof item.type === "string" ? item.type : "";
|
||||||
|
if (itemType === "agent_message") {
|
||||||
|
const text = typeof item.text === "string" ? item.text : "";
|
||||||
|
if (text) return [{ kind: "assistant", ts, text }];
|
||||||
|
}
|
||||||
|
if (itemType === "tool_use") {
|
||||||
|
return [{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: typeof item.name === "string" ? item.name : "unknown",
|
||||||
|
input: item.input ?? {},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "turn.completed") {
|
||||||
|
const usage = asRecord(parsed.usage) ?? {};
|
||||||
|
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
||||||
|
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
||||||
|
const cachedTokens = typeof usage.cached_input_tokens === "number" ? usage.cached_input_tokens : 0;
|
||||||
|
return [{
|
||||||
|
kind: "result",
|
||||||
|
ts,
|
||||||
|
text: "",
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
cachedTokens,
|
||||||
|
costUsd: 0,
|
||||||
|
subtype: "",
|
||||||
|
isError: false,
|
||||||
|
errors: [],
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
9
ui/src/adapters/http/build-config.ts
Normal file
9
ui/src/adapters/http/build-config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||||
|
|
||||||
|
export function buildHttpConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.url) ac.url = v.url;
|
||||||
|
ac.method = "POST";
|
||||||
|
ac.timeoutMs = 15000;
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
38
ui/src/adapters/http/config-fields.tsx
Normal file
38
ui/src/adapters/http/config-fields.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
DraftInput,
|
||||||
|
help,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
export function HttpConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<Field label="Webhook URL" hint={help.webhookUrl}>
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.url
|
||||||
|
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ url: v })
|
||||||
|
: mark("adapterConfig", "url", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/http/index.ts
Normal file
12
ui/src/adapters/http/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseHttpStdoutLine } from "./parse-stdout";
|
||||||
|
import { HttpConfigFields } from "./config-fields";
|
||||||
|
import { buildHttpConfig } from "./build-config";
|
||||||
|
|
||||||
|
export const httpUIAdapter: UIAdapterModule = {
|
||||||
|
type: "http",
|
||||||
|
label: "HTTP Webhook",
|
||||||
|
parseStdoutLine: parseHttpStdoutLine,
|
||||||
|
ConfigFields: HttpConfigFields,
|
||||||
|
buildAdapterConfig: buildHttpConfig,
|
||||||
|
};
|
||||||
5
ui/src/adapters/http/parse-stdout.ts
Normal file
5
ui/src/adapters/http/parse-stdout.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { TranscriptEntry } from "../types";
|
||||||
|
|
||||||
|
export function parseHttpStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
8
ui/src/adapters/index.ts
Normal file
8
ui/src/adapters/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { getUIAdapter } from "./registry";
|
||||||
|
export { buildTranscript } from "./transcript";
|
||||||
|
export type {
|
||||||
|
TranscriptEntry,
|
||||||
|
StdoutLineParser,
|
||||||
|
UIAdapterModule,
|
||||||
|
AdapterConfigFieldsProps,
|
||||||
|
} from "./types";
|
||||||
18
ui/src/adapters/process/build-config.ts
Normal file
18
ui/src/adapters/process/build-config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProcessConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.cwd) ac.cwd = v.cwd;
|
||||||
|
ac.timeoutSec = 0;
|
||||||
|
ac.graceSec = 15;
|
||||||
|
if (v.command) ac.command = v.command;
|
||||||
|
if (v.args) ac.args = parseCommaArgs(v.args);
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
77
ui/src/adapters/process/config-fields.tsx
Normal file
77
ui/src/adapters/process/config-fields.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
DraftInput,
|
||||||
|
help,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
function formatArgList(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommaArgs(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Command" hint={help.command}>
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.command
|
||||||
|
: eff("adapterConfig", "command", String(config.command ?? ""))
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ command: v })
|
||||||
|
: mark("adapterConfig", "command", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="e.g. node, python"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Args (comma-separated)" hint={help.args}>
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.args
|
||||||
|
: eff("adapterConfig", "args", formatArgList(config.args))
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ args: v })
|
||||||
|
: mark(
|
||||||
|
"adapterConfig",
|
||||||
|
"args",
|
||||||
|
v ? parseCommaArgs(v) : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="e.g. script.js, --flag"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/process/index.ts
Normal file
12
ui/src/adapters/process/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseProcessStdoutLine } from "./parse-stdout";
|
||||||
|
import { ProcessConfigFields } from "./config-fields";
|
||||||
|
import { buildProcessConfig } from "./build-config";
|
||||||
|
|
||||||
|
export const processUIAdapter: UIAdapterModule = {
|
||||||
|
type: "process",
|
||||||
|
label: "Shell Process",
|
||||||
|
parseStdoutLine: parseProcessStdoutLine,
|
||||||
|
ConfigFields: ProcessConfigFields,
|
||||||
|
buildAdapterConfig: buildProcessConfig,
|
||||||
|
};
|
||||||
5
ui/src/adapters/process/parse-stdout.ts
Normal file
5
ui/src/adapters/process/parse-stdout.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { TranscriptEntry } from "../types";
|
||||||
|
|
||||||
|
export function parseProcessStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
return [{ kind: "stdout", ts, text: line }];
|
||||||
|
}
|
||||||
13
ui/src/adapters/registry.ts
Normal file
13
ui/src/adapters/registry.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { UIAdapterModule } from "./types";
|
||||||
|
import { claudeLocalUIAdapter } from "./claude-local";
|
||||||
|
import { codexLocalUIAdapter } from "./codex-local";
|
||||||
|
import { processUIAdapter } from "./process";
|
||||||
|
import { httpUIAdapter } from "./http";
|
||||||
|
|
||||||
|
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||||
|
[claudeLocalUIAdapter, codexLocalUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getUIAdapter(type: string): UIAdapterModule {
|
||||||
|
return adaptersByType.get(type) ?? processUIAdapter;
|
||||||
|
}
|
||||||
36
ui/src/adapters/transcript.ts
Normal file
36
ui/src/adapters/transcript.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
||||||
|
|
||||||
|
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||||
|
|
||||||
|
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
let stdoutBuffer = "";
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (chunk.stream === "stderr") {
|
||||||
|
entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunk.stream === "system") {
|
||||||
|
entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = stdoutBuffer + chunk.chunk;
|
||||||
|
const lines = combined.split(/\r?\n/);
|
||||||
|
stdoutBuffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
entries.push(...parser(trimmed, chunk.ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailing = stdoutBuffer.trim();
|
||||||
|
if (trailing) {
|
||||||
|
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||||
|
entries.push(...parser(trailing, ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
39
ui/src/adapters/types.ts
Normal file
39
ui/src/adapters/types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { CreateConfigValues } from "../components/AgentConfigForm";
|
||||||
|
|
||||||
|
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[];
|
||||||
|
|
||||||
|
export interface AdapterConfigFieldsProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
isCreate: boolean;
|
||||||
|
adapterType: string;
|
||||||
|
/** Create mode: raw form values */
|
||||||
|
values: CreateConfigValues | null;
|
||||||
|
/** Create mode: setter for form values */
|
||||||
|
set: ((patch: Partial<CreateConfigValues>) => void) | null;
|
||||||
|
/** Edit mode: original adapterConfig from agent */
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
/** Edit mode: read effective value */
|
||||||
|
eff: <T>(group: "adapterConfig", field: string, original: T) => T;
|
||||||
|
/** Edit mode: mark field dirty */
|
||||||
|
mark: (group: "adapterConfig", field: string, value: unknown) => void;
|
||||||
|
/** Available models for dropdowns */
|
||||||
|
models: { id: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIAdapterModule {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
parseStdoutLine: StdoutLineParser;
|
||||||
|
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
|
||||||
|
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AGENT_ADAPTER_TYPES } from "@paperclip/shared";
|
import { AGENT_ADAPTER_TYPES } from "@paperclip/shared";
|
||||||
import type { Agent } from "@paperclip/shared";
|
import type { Agent } from "@paperclip/shared";
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
help,
|
help,
|
||||||
adapterLabels,
|
adapterLabels,
|
||||||
} from "./agent-config-primitives";
|
} from "./agent-config-primitives";
|
||||||
|
import { getUIAdapter } from "../adapters";
|
||||||
|
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
|
||||||
|
|
||||||
/* ---- Create mode values ---- */
|
/* ---- Create mode values ---- */
|
||||||
|
|
||||||
@@ -251,6 +253,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
? props.values.adapterType
|
? props.values.adapterType
|
||||||
: overlay.adapterType ?? props.agent.adapterType;
|
: overlay.adapterType ?? props.agent.adapterType;
|
||||||
const isLocal = adapterType === "claude_local" || adapterType === "codex_local";
|
const isLocal = adapterType === "claude_local" || adapterType === "codex_local";
|
||||||
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
|
||||||
// Fetch adapter models for the effective adapter type
|
// Fetch adapter models for the effective adapter type
|
||||||
const { data: fetchedModels } = useQuery({
|
const { data: fetchedModels } = useQuery({
|
||||||
@@ -259,6 +262,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
});
|
});
|
||||||
const models = fetchedModels ?? externalModels ?? [];
|
const models = fetchedModels ?? externalModels ?? [];
|
||||||
|
|
||||||
|
/** Props passed to adapter-specific config field components */
|
||||||
|
const adapterFieldProps = {
|
||||||
|
mode,
|
||||||
|
isCreate,
|
||||||
|
adapterType,
|
||||||
|
values: isCreate ? props.values : null,
|
||||||
|
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
|
||||||
|
config,
|
||||||
|
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
|
||||||
|
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
|
||||||
// Section toggle state — advanced always starts collapsed
|
// Section toggle state — advanced always starts collapsed
|
||||||
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
||||||
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
|
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
|
||||||
@@ -443,130 +459,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Claude-specific: Skip permissions */}
|
{/* Adapter-specific fields */}
|
||||||
{adapterType === "claude_local" && (
|
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||||
<ToggleField
|
|
||||||
label="Skip permissions"
|
|
||||||
hint={help.dangerouslySkipPermissions}
|
|
||||||
checked={
|
|
||||||
isCreate
|
|
||||||
? val!.dangerouslySkipPermissions
|
|
||||||
: eff(
|
|
||||||
"adapterConfig",
|
|
||||||
"dangerouslySkipPermissions",
|
|
||||||
config.dangerouslySkipPermissions !== false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ dangerouslySkipPermissions: v })
|
|
||||||
: mark("adapterConfig", "dangerouslySkipPermissions", v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Codex-specific: Bypass sandbox + Search */}
|
|
||||||
{adapterType === "codex_local" && (
|
|
||||||
<>
|
|
||||||
<ToggleField
|
|
||||||
label="Bypass sandbox"
|
|
||||||
hint={help.dangerouslyBypassSandbox}
|
|
||||||
checked={
|
|
||||||
isCreate
|
|
||||||
? val!.dangerouslyBypassSandbox
|
|
||||||
: eff(
|
|
||||||
"adapterConfig",
|
|
||||||
"dangerouslyBypassApprovalsAndSandbox",
|
|
||||||
config.dangerouslyBypassApprovalsAndSandbox !== false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ dangerouslyBypassSandbox: v })
|
|
||||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ToggleField
|
|
||||||
label="Enable search"
|
|
||||||
hint={help.search}
|
|
||||||
checked={
|
|
||||||
isCreate
|
|
||||||
? val!.search
|
|
||||||
: eff("adapterConfig", "search", !!config.search)
|
|
||||||
}
|
|
||||||
onChange={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ search: v })
|
|
||||||
: mark("adapterConfig", "search", v)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Process-specific */}
|
|
||||||
{adapterType === "process" && (
|
|
||||||
<>
|
|
||||||
<Field label="Command" hint={help.command}>
|
|
||||||
<DraftInput
|
|
||||||
value={
|
|
||||||
isCreate
|
|
||||||
? val!.command
|
|
||||||
: eff("adapterConfig", "command", String(config.command ?? ""))
|
|
||||||
}
|
|
||||||
onCommit={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ command: v })
|
|
||||||
: mark("adapterConfig", "command", v || undefined)
|
|
||||||
}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="e.g. node, python"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Args (comma-separated)" hint={help.args}>
|
|
||||||
<DraftInput
|
|
||||||
value={
|
|
||||||
isCreate
|
|
||||||
? val!.args
|
|
||||||
: eff("adapterConfig", "args", formatArgList(config.args))
|
|
||||||
}
|
|
||||||
onCommit={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ args: v })
|
|
||||||
: mark(
|
|
||||||
"adapterConfig",
|
|
||||||
"args",
|
|
||||||
v ? parseCommaArgs(v) : undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="e.g. script.js, --flag"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HTTP-specific */}
|
|
||||||
{adapterType === "http" && (
|
|
||||||
<Field label="Webhook URL" hint={help.webhookUrl}>
|
|
||||||
<DraftInput
|
|
||||||
value={
|
|
||||||
isCreate
|
|
||||||
? val!.url
|
|
||||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
|
||||||
}
|
|
||||||
onCommit={(v) =>
|
|
||||||
isCreate
|
|
||||||
? set!({ url: v })
|
|
||||||
: mark("adapterConfig", "url", v || undefined)
|
|
||||||
}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Advanced adapter section — collapsible in both modes */}
|
{/* Advanced adapter section — collapsible in both modes */}
|
||||||
{isLocal && (
|
{isLocal && (
|
||||||
@@ -630,27 +524,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
{adapterType === "claude_local" && (
|
{adapterType === "claude_local" && (
|
||||||
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||||
{isCreate ? (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className={inputClass}
|
|
||||||
value={val!.maxTurnsPerRun}
|
|
||||||
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DraftNumberInput
|
|
||||||
value={eff(
|
|
||||||
"adapterConfig",
|
|
||||||
"maxTurnsPerRun",
|
|
||||||
Number(config.maxTurnsPerRun ?? 80),
|
|
||||||
)}
|
|
||||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
|
|
||||||
immediate
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
||||||
|
|||||||
@@ -29,28 +29,7 @@ import {
|
|||||||
defaultCreateValues,
|
defaultCreateValues,
|
||||||
type CreateConfigValues,
|
type CreateConfigValues,
|
||||||
} from "./AgentConfigForm";
|
} from "./AgentConfigForm";
|
||||||
|
import { getUIAdapter } from "../adapters";
|
||||||
function parseCommaArgs(value: string): string[] {
|
|
||||||
return value
|
|
||||||
.split(",")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvVars(text: string): Record<string, string> {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const line of text.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
const eq = trimmed.indexOf("=");
|
|
||||||
if (eq <= 0) continue;
|
|
||||||
const key = trimmed.slice(0, eq).trim();
|
|
||||||
const valueAtKey = trimmed.slice(eq + 1);
|
|
||||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
||||||
env[key] = valueAtKey;
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewAgentDialog() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
const { newAgentOpen, closeNewAgent } = useDialog();
|
||||||
@@ -116,34 +95,8 @@ export function NewAgentDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAdapterConfig() {
|
function buildAdapterConfig() {
|
||||||
const v = configValues;
|
const adapter = getUIAdapter(configValues.adapterType);
|
||||||
const ac: Record<string, unknown> = {};
|
return adapter.buildAdapterConfig(configValues);
|
||||||
if (v.cwd) ac.cwd = v.cwd;
|
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
|
||||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
|
||||||
if (v.model) ac.model = v.model;
|
|
||||||
ac.timeoutSec = 0;
|
|
||||||
ac.graceSec = 15;
|
|
||||||
const env = parseEnvVars(v.envVars);
|
|
||||||
if (Object.keys(env).length > 0) ac.env = env;
|
|
||||||
|
|
||||||
if (v.adapterType === "claude_local") {
|
|
||||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
|
||||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
|
||||||
if (v.command) ac.command = v.command;
|
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
|
||||||
} else if (v.adapterType === "codex_local") {
|
|
||||||
ac.search = v.search;
|
|
||||||
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
|
||||||
if (v.command) ac.command = v.command;
|
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
|
||||||
} else if (v.adapterType === "process") {
|
|
||||||
if (v.command) ac.command = v.command;
|
|
||||||
if (v.args) ac.args = parseCommaArgs(v.args);
|
|
||||||
} else if (v.adapterType === "http") {
|
|
||||||
if (v.url) ac.url = v.url;
|
|
||||||
}
|
|
||||||
return ac;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { getUIAdapter } from "../adapters";
|
||||||
|
import { defaultCreateValues } from "./AgentConfigForm";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Bot,
|
Bot,
|
||||||
@@ -97,33 +99,17 @@ export function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAdapterConfig(): Record<string, unknown> {
|
function buildAdapterConfig(): Record<string, unknown> {
|
||||||
if (adapterType === "claude_local") {
|
const adapter = getUIAdapter(adapterType);
|
||||||
return {
|
return adapter.buildAdapterConfig({
|
||||||
...(cwd ? { cwd } : {}),
|
...defaultCreateValues,
|
||||||
...(model ? { model } : {}),
|
adapterType,
|
||||||
timeoutSec: 900,
|
cwd,
|
||||||
graceSec: 15,
|
model,
|
||||||
maxTurnsPerRun: 80,
|
command,
|
||||||
dangerouslySkipPermissions: true,
|
args,
|
||||||
};
|
url,
|
||||||
}
|
dangerouslySkipPermissions: adapterType === "claude_local",
|
||||||
if (adapterType === "process") {
|
});
|
||||||
return {
|
|
||||||
...(command ? { command } : {}),
|
|
||||||
args: args
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
timeoutSec: 900,
|
|
||||||
graceSec: 15,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// http
|
|
||||||
return {
|
|
||||||
...(url ? { url } : {}),
|
|
||||||
method: "POST",
|
|
||||||
timeoutMs: 15000,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStep1Next() {
|
async function handleStep1Next() {
|
||||||
@@ -594,11 +580,7 @@ export function OnboardingWizard() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{agentName}</p>
|
<p className="text-sm font-medium truncate">{agentName}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{adapterType === "claude_local"
|
{getUIAdapter(adapterType).label}
|
||||||
? "Claude Code"
|
|
||||||
: adapterType === "process"
|
|
||||||
? "Shell Command"
|
|
||||||
: "HTTP Webhook"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { queryKeys } from "../lib/queryKeys";
|
|||||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||||
|
import { getUIAdapter, buildTranscript } from "../adapters";
|
||||||
|
import type { TranscriptEntry } from "../adapters";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||||
@@ -106,150 +108,11 @@ function runMetrics(run: HeartbeatRun) {
|
|||||||
|
|
||||||
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|
||||||
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;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function asNumber(value: unknown): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorText(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
const rec = asRecord(value);
|
|
||||||
if (!rec) return "";
|
|
||||||
const msg =
|
|
||||||
(typeof rec.message === "string" && rec.message) ||
|
|
||||||
(typeof rec.error === "string" && rec.error) ||
|
|
||||||
(typeof rec.code === "string" && rec.code) ||
|
|
||||||
"";
|
|
||||||
if (msg) return msg;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(rec);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|
||||||
const parsed = asRecord(safeJsonParse(line));
|
|
||||||
if (!parsed) {
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
||||||
if (type === "system" && parsed.subtype === "init") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
kind: "init",
|
|
||||||
ts,
|
|
||||||
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
|
||||||
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
const message = asRecord(parsed.message) ?? {};
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
for (const blockRaw of content) {
|
|
||||||
const block = asRecord(blockRaw);
|
|
||||||
if (!block) continue;
|
|
||||||
const blockType = typeof block.type === "string" ? block.type : "";
|
|
||||||
if (blockType === "text") {
|
|
||||||
const text = typeof block.text === "string" ? block.text : "";
|
|
||||||
if (text) entries.push({ kind: "assistant", ts, text });
|
|
||||||
} else if (blockType === "tool_use") {
|
|
||||||
entries.push({
|
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name: typeof block.name === "string" ? block.name : "unknown",
|
|
||||||
input: block.input ?? {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
const usage = asRecord(parsed.usage) ?? {};
|
|
||||||
const inputTokens = asNumber(usage.input_tokens);
|
|
||||||
const outputTokens = asNumber(usage.output_tokens);
|
|
||||||
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
|
||||||
const costUsd = asNumber(parsed.total_cost_usd);
|
|
||||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
|
||||||
const isError = parsed.is_error === true;
|
|
||||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
|
||||||
const text = typeof parsed.result === "string" ? parsed.result : "";
|
|
||||||
return [{
|
|
||||||
kind: "result",
|
|
||||||
ts,
|
|
||||||
text,
|
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
|
||||||
cachedTokens,
|
|
||||||
costUsd,
|
|
||||||
subtype,
|
|
||||||
isError,
|
|
||||||
errors,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTranscript(chunks: RunLogChunk[]): TranscriptEntry[] {
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
let stdoutBuffer = "";
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
if (chunk.stream === "stderr") {
|
|
||||||
entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (chunk.stream === "system") {
|
|
||||||
entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const combined = stdoutBuffer + chunk.chunk;
|
|
||||||
const lines = combined.split(/\r?\n/);
|
|
||||||
stdoutBuffer = lines.pop() ?? "";
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
entries.push(...parseClaudeStdoutLine(trimmed, chunk.ts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const trailing = stdoutBuffer.trim();
|
|
||||||
if (trailing) {
|
|
||||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
|
||||||
entries.push(...parseClaudeStdoutLine(trailing, ts));
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJsonParse(text: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentDetail() {
|
export function AgentDetail() {
|
||||||
const { agentId, runId: urlRunId } = useParams<{ agentId: string; runId?: string }>();
|
const { agentId, runId: urlRunId } = useParams<{ agentId: string; runId?: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
@@ -608,7 +471,7 @@ export function AgentDetail() {
|
|||||||
|
|
||||||
{/* RUNS TAB */}
|
{/* RUNS TAB */}
|
||||||
<TabsContent value="runs" className="mt-4">
|
<TabsContent value="runs" className="mt-4">
|
||||||
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} />
|
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} adapterType={agent.adapterType} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ISSUES TAB */}
|
{/* ISSUES TAB */}
|
||||||
@@ -707,7 +570,7 @@ function ConfigurationTab({
|
|||||||
|
|
||||||
/* ---- Runs Tab ---- */
|
/* ---- Runs Tab ---- */
|
||||||
|
|
||||||
function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null }) {
|
function RunsTab({ runs, companyId, agentId, selectedRunId, adapterType }: { runs: HeartbeatRun[]; companyId: string; agentId: string; selectedRunId: string | null; adapterType: string }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
if (runs.length === 0) {
|
if (runs.length === 0) {
|
||||||
@@ -785,7 +648,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatR
|
|||||||
{/* Right: run detail — natural height, page scrolls */}
|
{/* Right: run detail — natural height, page scrolls */}
|
||||||
{selectedRun && (
|
{selectedRun && (
|
||||||
<div className="flex-1 min-w-0 pl-4">
|
<div className="flex-1 min-w-0 pl-4">
|
||||||
<RunDetail key={selectedRun.id} run={selectedRun} />
|
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -794,7 +657,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatR
|
|||||||
|
|
||||||
/* ---- Run Detail (expanded) ---- */
|
/* ---- Run Detail (expanded) ---- */
|
||||||
|
|
||||||
function RunDetail({ run }: { run: HeartbeatRun }) {
|
function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const metrics = runMetrics(run);
|
const metrics = runMetrics(run);
|
||||||
|
|
||||||
@@ -939,14 +802,14 @@ function RunDetail({ run }: { run: HeartbeatRun }) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Log viewer */}
|
{/* Log viewer */}
|
||||||
<LogViewer run={run} />
|
<LogViewer run={run} adapterType={adapterType} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Log Viewer ---- */
|
/* ---- Log Viewer ---- */
|
||||||
|
|
||||||
function LogViewer({ run }: { run: HeartbeatRun }) {
|
function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
||||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||||
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
|
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -1099,7 +962,8 @@ function LogViewer({ run }: { run: HeartbeatRun }) {
|
|||||||
return asRecord(evt?.payload ?? null);
|
return asRecord(evt?.payload ?? null);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const transcript = useMemo(() => buildTranscript(logLines), [logLines]);
|
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
||||||
|
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]);
|
||||||
|
|
||||||
if (loading && logLoading) {
|
if (loading && logLoading) {
|
||||||
return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
return <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
||||||
|
|||||||
Reference in New Issue
Block a user