From 47ccd946b6d864846cad7496d3b07af4790a1b28 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Wed, 18 Feb 2026 13:53:03 -0600 Subject: [PATCH] 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 --- cli/src/adapters/claude-local/format-event.ts | 99 ++ cli/src/adapters/claude-local/index.ts | 7 + cli/src/adapters/codex-local/format-event.ts | 56 ++ cli/src/adapters/codex-local/index.ts | 7 + cli/src/adapters/http/format-event.ts | 4 + cli/src/adapters/http/index.ts | 7 + cli/src/adapters/index.ts | 2 + cli/src/adapters/process/format-event.ts | 4 + cli/src/adapters/process/index.ts | 7 + cli/src/adapters/registry.ts | 13 + cli/src/adapters/types.ts | 4 + cli/src/commands/heartbeat-run.ts | 89 +- server/src/adapters/claude-local/execute.ts | 197 ++++ server/src/adapters/claude-local/index.ts | 12 + server/src/adapters/claude-local/parse.ts | 132 +++ server/src/adapters/codex-local/execute.ts | 117 +++ server/src/adapters/codex-local/index.ts | 12 + server/src/adapters/codex-local/parse.ts | 47 + server/src/adapters/http/execute.ts | 42 + server/src/adapters/http/index.ts | 8 + server/src/adapters/index.ts | 11 + server/src/adapters/process/execute.ts | 77 ++ server/src/adapters/process/index.ts | 8 + server/src/adapters/registry.ts | 22 + server/src/adapters/types.ts | 51 + server/src/adapters/utils.ts | 248 +++++ server/src/routes/agents.ts | 19 +- server/src/services/heartbeat.ts | 918 +----------------- ui/src/adapters/claude-local/build-config.ts | 40 + .../adapters/claude-local/config-fields.tsx | 78 ++ ui/src/adapters/claude-local/index.ts | 12 + ui/src/adapters/claude-local/parse-stdout.ts | 103 ++ ui/src/adapters/codex-local/build-config.ts | 40 + ui/src/adapters/codex-local/config-fields.tsx | 51 + ui/src/adapters/codex-local/index.ts | 12 + ui/src/adapters/codex-local/parse-stdout.ts | 73 ++ ui/src/adapters/http/build-config.ts | 9 + ui/src/adapters/http/config-fields.tsx | 38 + ui/src/adapters/http/index.ts | 12 + ui/src/adapters/http/parse-stdout.ts | 5 + ui/src/adapters/index.ts | 8 + ui/src/adapters/process/build-config.ts | 18 + ui/src/adapters/process/config-fields.tsx | 77 ++ ui/src/adapters/process/index.ts | 12 + ui/src/adapters/process/parse-stdout.ts | 5 + ui/src/adapters/registry.ts | 13 + ui/src/adapters/transcript.ts | 36 + ui/src/adapters/types.ts | 39 + ui/src/components/AgentConfigForm.tsx | 166 +--- ui/src/components/NewAgentDialog.tsx | 53 +- ui/src/components/OnboardingWizard.tsx | 46 +- ui/src/pages/AgentDetail.tsx | 156 +-- 52 files changed, 1961 insertions(+), 1361 deletions(-) create mode 100644 cli/src/adapters/claude-local/format-event.ts create mode 100644 cli/src/adapters/claude-local/index.ts create mode 100644 cli/src/adapters/codex-local/format-event.ts create mode 100644 cli/src/adapters/codex-local/index.ts create mode 100644 cli/src/adapters/http/format-event.ts create mode 100644 cli/src/adapters/http/index.ts create mode 100644 cli/src/adapters/index.ts create mode 100644 cli/src/adapters/process/format-event.ts create mode 100644 cli/src/adapters/process/index.ts create mode 100644 cli/src/adapters/registry.ts create mode 100644 cli/src/adapters/types.ts create mode 100644 server/src/adapters/claude-local/execute.ts create mode 100644 server/src/adapters/claude-local/index.ts create mode 100644 server/src/adapters/claude-local/parse.ts create mode 100644 server/src/adapters/codex-local/execute.ts create mode 100644 server/src/adapters/codex-local/index.ts create mode 100644 server/src/adapters/codex-local/parse.ts create mode 100644 server/src/adapters/http/execute.ts create mode 100644 server/src/adapters/http/index.ts create mode 100644 server/src/adapters/index.ts create mode 100644 server/src/adapters/process/execute.ts create mode 100644 server/src/adapters/process/index.ts create mode 100644 server/src/adapters/registry.ts create mode 100644 server/src/adapters/types.ts create mode 100644 server/src/adapters/utils.ts create mode 100644 ui/src/adapters/claude-local/build-config.ts create mode 100644 ui/src/adapters/claude-local/config-fields.tsx create mode 100644 ui/src/adapters/claude-local/index.ts create mode 100644 ui/src/adapters/claude-local/parse-stdout.ts create mode 100644 ui/src/adapters/codex-local/build-config.ts create mode 100644 ui/src/adapters/codex-local/config-fields.tsx create mode 100644 ui/src/adapters/codex-local/index.ts create mode 100644 ui/src/adapters/codex-local/parse-stdout.ts create mode 100644 ui/src/adapters/http/build-config.ts create mode 100644 ui/src/adapters/http/config-fields.tsx create mode 100644 ui/src/adapters/http/index.ts create mode 100644 ui/src/adapters/http/parse-stdout.ts create mode 100644 ui/src/adapters/index.ts create mode 100644 ui/src/adapters/process/build-config.ts create mode 100644 ui/src/adapters/process/config-fields.tsx create mode 100644 ui/src/adapters/process/index.ts create mode 100644 ui/src/adapters/process/parse-stdout.ts create mode 100644 ui/src/adapters/registry.ts create mode 100644 ui/src/adapters/transcript.ts create mode 100644 ui/src/adapters/types.ts diff --git a/cli/src/adapters/claude-local/format-event.ts b/cli/src/adapters/claude-local/format-event.ts new file mode 100644 index 00000000..08423d6e --- /dev/null +++ b/cli/src/adapters/claude-local/format-event.ts @@ -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; + 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 | null = null; + try { + parsed = JSON.parse(line) as Record; + } 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) + : {}; + 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; + 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) + : {}; + 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)); + } +} diff --git a/cli/src/adapters/claude-local/index.ts b/cli/src/adapters/claude-local/index.ts new file mode 100644 index 00000000..9ea34c83 --- /dev/null +++ b/cli/src/adapters/claude-local/index.ts @@ -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, +}; diff --git a/cli/src/adapters/codex-local/format-event.ts b/cli/src/adapters/codex-local/format-event.ts new file mode 100644 index 00000000..f6fa5539 --- /dev/null +++ b/cli/src/adapters/codex-local/format-event.ts @@ -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 | null = null; + try { + parsed = JSON.parse(line) as Record; + } 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) + : 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) + : {}; + 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); +} diff --git a/cli/src/adapters/codex-local/index.ts b/cli/src/adapters/codex-local/index.ts new file mode 100644 index 00000000..68dc19b2 --- /dev/null +++ b/cli/src/adapters/codex-local/index.ts @@ -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, +}; diff --git a/cli/src/adapters/http/format-event.ts b/cli/src/adapters/http/format-event.ts new file mode 100644 index 00000000..fe141ae3 --- /dev/null +++ b/cli/src/adapters/http/format-event.ts @@ -0,0 +1,4 @@ +export function printHttpStdoutEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (line) console.log(line); +} diff --git a/cli/src/adapters/http/index.ts b/cli/src/adapters/http/index.ts new file mode 100644 index 00000000..0e684dc6 --- /dev/null +++ b/cli/src/adapters/http/index.ts @@ -0,0 +1,7 @@ +import type { CLIAdapterModule } from "../types.js"; +import { printHttpStdoutEvent } from "./format-event.js"; + +export const httpCLIAdapter: CLIAdapterModule = { + type: "http", + formatStdoutEvent: printHttpStdoutEvent, +}; diff --git a/cli/src/adapters/index.ts b/cli/src/adapters/index.ts new file mode 100644 index 00000000..d8d30aba --- /dev/null +++ b/cli/src/adapters/index.ts @@ -0,0 +1,2 @@ +export { getCLIAdapter } from "./registry.js"; +export type { CLIAdapterModule } from "./types.js"; diff --git a/cli/src/adapters/process/format-event.ts b/cli/src/adapters/process/format-event.ts new file mode 100644 index 00000000..c1840f50 --- /dev/null +++ b/cli/src/adapters/process/format-event.ts @@ -0,0 +1,4 @@ +export function printProcessStdoutEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (line) console.log(line); +} diff --git a/cli/src/adapters/process/index.ts b/cli/src/adapters/process/index.ts new file mode 100644 index 00000000..ff76ef47 --- /dev/null +++ b/cli/src/adapters/process/index.ts @@ -0,0 +1,7 @@ +import type { CLIAdapterModule } from "../types.js"; +import { printProcessStdoutEvent } from "./format-event.js"; + +export const processCLIAdapter: CLIAdapterModule = { + type: "process", + formatStdoutEvent: printProcessStdoutEvent, +}; diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts new file mode 100644 index 00000000..0c9ae367 --- /dev/null +++ b/cli/src/adapters/registry.ts @@ -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( + [claudeLocalCLIAdapter, codexLocalCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]), +); + +export function getCLIAdapter(type: string): CLIAdapterModule { + return adaptersByType.get(type) ?? processCLIAdapter; +} diff --git a/cli/src/adapters/types.ts b/cli/src/adapters/types.ts new file mode 100644 index 00000000..00a69cb7 --- /dev/null +++ b/cli/src/adapters/types.ts @@ -0,0 +1,4 @@ +export interface CLIAdapterModule { + type: string; + formatStdoutEvent: (line: string, debug: boolean) => void; +} diff --git a/cli/src/commands/heartbeat-run.ts b/cli/src/commands/heartbeat-run.ts index ecdd7f8c..288a42ba 100644 --- a/cli/src/commands/heartbeat-run.ts +++ b/cli/src/commands/heartbeat-run.ts @@ -3,6 +3,7 @@ import pc from "picocolors"; import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@paperclip/shared"; import type { PaperclipConfig } from "../config/schema.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_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 { const debug = Boolean(opts.debug); const parsedTimeout = Number.parseInt(opts.timeoutMs, 10); @@ -154,86 +157,8 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { } }; - const printClaudeStreamEvent = (raw: string) => { - const line = raw.trim(); - if (!line) return; - - let parsed: Record | null = null; - try { - parsed = JSON.parse(line) as Record; - } 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) - : {}; - 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; - 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) - : {}; - 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 adapterType: AdapterType = agent.adapterType ?? "claude_local"; + const cliAdapter = getCLIAdapter(adapterType); const handleStreamChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => { if (debug) { @@ -250,7 +175,7 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { const lines = combined.split(/\r?\n/); stdoutJsonBuffer = lines.pop() ?? ""; for (const line of lines) { - printClaudeStreamEvent(line); + cliAdapter.formatStdoutEvent(line, debug); } }; @@ -358,7 +283,7 @@ export async function heartbeatRun(opts: HeartbeatRunOptions): Promise { if (finalStatus) { if (!debug && stdoutJsonBuffer.trim()) { - printClaudeStreamEvent(stdoutJsonBuffer); + cliAdapter.formatStdoutEvent(stdoutJsonBuffer, debug); stdoutJsonBuffer = ""; } const label = `Run ${activeRunId} completed with status ${finalStatus}`; diff --git a/server/src/adapters/claude-local/execute.ts b/server/src/adapters/claude-local/execute.ts new file mode 100644 index 00000000..7afee0d5 --- /dev/null +++ b/server/src/adapters/claude-local/execute.ts @@ -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 { + 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 = { ...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 ? `` : 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; + parsed: Record | 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 }); +} diff --git a/server/src/adapters/claude-local/index.ts b/server/src/adapters/claude-local/index.ts new file mode 100644 index 00000000..f5a50b80 --- /dev/null +++ b/server/src/adapters/claude-local/index.ts @@ -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" }, + ], +}; diff --git a/server/src/adapters/claude-local/parse.ts b/server/src/adapters/claude-local/parse.ts new file mode 100644 index 00000000..ab498837 --- /dev/null +++ b/server/src/adapters/claude-local/parse.ts @@ -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 | 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; + 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 | 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[] { + 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; + 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 | 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): 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), + ); +} diff --git a/server/src/adapters/codex-local/execute.ts b/server/src/adapters/codex-local/execute.ts new file mode 100644 index 00000000..46286f25 --- /dev/null +++ b/server/src/adapters/codex-local/execute.ts @@ -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 { + 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 = { ...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 ``; + if (sessionId && idx === args.length - 1) return ``; + 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, + }; +} diff --git a/server/src/adapters/codex-local/index.ts b/server/src/adapters/codex-local/index.ts new file mode 100644 index 00000000..3375432f --- /dev/null +++ b/server/src/adapters/codex-local/index.ts @@ -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" }, + ], +}; diff --git a/server/src/adapters/codex-local/parse.ts b/server/src/adapters/codex-local/parse.ts new file mode 100644 index 00000000..3e232a29 --- /dev/null +++ b/server/src/adapters/codex-local/parse.ts @@ -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, + }; +} diff --git a/server/src/adapters/http/execute.ts b/server/src/adapters/http/execute.ts new file mode 100644 index 00000000..e4044570 --- /dev/null +++ b/server/src/adapters/http/execute.ts @@ -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 { + 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; + 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); + } +} diff --git a/server/src/adapters/http/index.ts b/server/src/adapters/http/index.ts new file mode 100644 index 00000000..24731af1 --- /dev/null +++ b/server/src/adapters/http/index.ts @@ -0,0 +1,8 @@ +import type { ServerAdapterModule } from "../types.js"; +import { execute } from "./execute.js"; + +export const httpAdapter: ServerAdapterModule = { + type: "http", + execute, + models: [], +}; diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts new file mode 100644 index 00000000..b314cbb0 --- /dev/null +++ b/server/src/adapters/index.ts @@ -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"; diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts new file mode 100644 index 00000000..93df8dab --- /dev/null +++ b/server/src/adapters/process/execute.ts @@ -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 { + 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 = { ...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, + }, + }; +} diff --git a/server/src/adapters/process/index.ts b/server/src/adapters/process/index.ts new file mode 100644 index 00000000..630675e4 --- /dev/null +++ b/server/src/adapters/process/index.ts @@ -0,0 +1,8 @@ +import type { ServerAdapterModule } from "../types.js"; +import { execute } from "./execute.js"; + +export const processAdapter: ServerAdapterModule = { + type: "process", + execute, + models: [], +}; diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts new file mode 100644 index 00000000..1ff84357 --- /dev/null +++ b/server/src/adapters/registry.ts @@ -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( + [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 ?? []; +} diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts new file mode 100644 index 00000000..906e2977 --- /dev/null +++ b/server/src/adapters/types.ts @@ -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 | null; + summary?: string | null; + clearSession?: boolean; +} + +export interface AdapterInvocationMeta { + adapterType: string; + command: string; + cwd?: string; + commandArgs?: string[]; + env?: Record; + prompt?: string; + context?: Record; +} + +export type AgentRecord = typeof agents.$inferSelect; +export type AgentRuntimeStateRecord = typeof agentRuntimeState.$inferSelect; + +export interface AdapterExecutionContext { + runId: string; + agent: AgentRecord; + runtime: AgentRuntimeStateRecord; + config: Record; + context: Record; + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + onMeta?: (meta: AdapterInvocationMeta) => Promise; +} + +export interface ServerAdapterModule { + type: string; + execute(ctx: AdapterExecutionContext): Promise; + models?: { id: string; label: string }[]; +} diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts new file mode 100644 index 00000000..7c34029d --- /dev/null +++ b/server/src/adapters/utils.ts @@ -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(); +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 { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return {}; + } + return value as Record; +} + +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 | null { + try { + return JSON.parse(value) as Record; + } 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, 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)[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) { + return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); +} + +export function redactEnvForLogs(env: Record): Record { + const redacted: Record = {}; + 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 { + const vars: Record = { + 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; + timeoutSec: number; + graceSec: number; + onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; + }, +): Promise { + return new Promise((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 = 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, + }); + }); + }); + }); +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d41b609d..9a26c0c9 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -9,31 +9,16 @@ import { import { validate } from "../middleware/validate.js"; import { agentService, heartbeatService, logActivity } from "../services/index.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { listAdapterModels } from "../adapters/index.js"; export function agentRoutes(db: Db) { const router = Router(); const svc = agentService(db); const heartbeat = heartbeatService(db); - // Static model lists for adapters — can be extended to query CLIs dynamically - const adapterModels: Record = { - 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) => { const type = req.params.type as string; - const models = adapterModels[type] ?? []; + const models = listAdapterModels(type); res.json(models); }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f42630f2..25754766 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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 type { Db } from "@paperclip/db"; import { @@ -15,49 +12,14 @@ import { conflict, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { publishLiveEvent } from "./live-events.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 { - child: ChildProcess; - graceSec: number; -} +const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; -interface RunProcessResult { - exitCode: number | null; - 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 | null; - summary?: string | null; - clearSession?: boolean; -} - -interface AdapterInvocationMeta { - adapterType: string; - command: string; - cwd?: string; - commandArgs?: string[]; - env?: Record; - prompt?: string; - context?: Record; +function appendExcerpt(prev: string, chunk: string) { + return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); } interface WakeupOptions { @@ -71,418 +33,6 @@ interface WakeupOptions { contextSnapshot?: Record; } -const runningProcesses = new Map(); -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 { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - return {}; - } - return value as Record; -} - -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 | null { - try { - return JSON.parse(value) as Record; - } 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, 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)[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) { - 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 | 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[] { - 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; - 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): 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 | 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; - 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 | 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): Record { - const redacted: Record = {}; - 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; - timeoutSec: number; - graceSec: number; - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; - }, -): Promise { - return new Promise((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 = 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 { - const vars: Record = { - 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) { const runLogStore = getRunLogStore(); @@ -717,414 +267,6 @@ export function heartbeatService(db: Db) { } } - async function executeHttpRun( - runId: string, - agentId: string, - config: Record, - context: Record, - ): Promise { - 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; - 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, - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, - onMeta?: (meta: AdapterInvocationMeta) => Promise, - ): Promise { - 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 = { ...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, - context: Record, - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, - onMeta?: (meta: AdapterInvocationMeta) => Promise, - ): Promise { - 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 = { ...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 ? `` : 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; - parsed: Record | 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, - context: Record, - onLog: (stream: "stdout" | "stderr", chunk: string) => Promise, - onMeta?: (meta: AdapterInvocationMeta) => Promise, - ): Promise { - 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 = { ...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 ``; - if (sessionId && idx === args.length - 1) return ``; - 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) { const run = await getRun(runId); if (!run) return; @@ -1238,20 +380,20 @@ export function heartbeatService(db: Db) { stream: "system", level: "info", message: "adapter invocation", - payload: meta as Record, + payload: meta as unknown as Record, }); }; - let adapterResult: AdapterExecutionResult; - if (agent.adapterType === "http") { - adapterResult = await executeHttpRun(run.id, agent.id, config, context); - } else if (agent.adapterType === "claude_local") { - adapterResult = await executeClaudeLocalRun(run.id, agent, runtime, config, context, onLog, onAdapterMeta); - } else if (agent.adapterType === "codex_local") { - adapterResult = await executeCodexLocalRun(run.id, agent, runtime, config, context, onLog, onAdapterMeta); - } else { - adapterResult = await executeProcessRun(run.id, agent, config, onLog, onAdapterMeta); - } + const adapter = getServerAdapter(agent.adapterType); + const adapterResult = await adapter.execute({ + runId: run.id, + agent, + runtime, + config, + context, + onLog, + onMeta: onAdapterMeta, + }); let outcome: "succeeded" | "failed" | "cancelled" | "timed_out"; const latestRun = await getRun(run.id); @@ -1473,9 +615,9 @@ export function heartbeatService(db: Db) { .returning() .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) .values({ companyId: agent.companyId, @@ -1485,7 +627,7 @@ export function heartbeatService(db: Db) { status: "queued", wakeupRequestId: wakeupRequest.id, contextSnapshot, - sessionIdBefore: runtime?.sessionId ?? null, + sessionIdBefore: runtimeForRun?.sessionId ?? null, }) .returning() .then((rows) => rows[0]); @@ -1493,28 +635,28 @@ export function heartbeatService(db: Db) { await db .update(agentWakeupRequests) .set({ - runId: run.id, + runId: newRun.id, updatedAt: new Date(), }) .where(eq(agentWakeupRequests.id, wakeupRequest.id)); publishLiveEvent({ - companyId: run.companyId, + companyId: newRun.companyId, type: "heartbeat.run.queued", payload: { - runId: run.id, - agentId: run.agentId, - invocationSource: run.invocationSource, - triggerDetail: run.triggerDetail, - wakeupRequestId: run.wakeupRequestId, + runId: newRun.id, + agentId: newRun.agentId, + invocationSource: newRun.invocationSource, + triggerDetail: newRun.triggerDetail, + wakeupRequestId: newRun.wakeupRequestId, }, }); - void executeRun(run.id).catch((err) => { - logger.error({ err, runId: run.id }, "heartbeat execution failed"); + void executeRun(newRun.id).catch((err) => { + logger.error({ err, runId: newRun.id }, "heartbeat execution failed"); }); - return run; + return newRun; } return { diff --git a/ui/src/adapters/claude-local/build-config.ts b/ui/src/adapters/claude-local/build-config.ts new file mode 100644 index 00000000..999b114b --- /dev/null +++ b/ui/src/adapters/claude-local/build-config.ts @@ -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 { + const env: Record = {}; + 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 { + const ac: Record = {}; + 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; +} diff --git a/ui/src/adapters/claude-local/config-fields.tsx b/ui/src/adapters/claude-local/config-fields.tsx new file mode 100644 index 00000000..1e7804ba --- /dev/null +++ b/ui/src/adapters/claude-local/config-fields.tsx @@ -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 ( + <> + + 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 ( + + {isCreate ? ( + set!({ maxTurnsPerRun: Number(e.target.value) })} + /> + ) : ( + mark("adapterConfig", "maxTurnsPerRun", v || 80)} + immediate + className={inputClass} + /> + )} + + ); +} diff --git a/ui/src/adapters/claude-local/index.ts b/ui/src/adapters/claude-local/index.ts new file mode 100644 index 00000000..b6efdb67 --- /dev/null +++ b/ui/src/adapters/claude-local/index.ts @@ -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, +}; diff --git a/ui/src/adapters/claude-local/parse-stdout.ts b/ui/src/adapters/claude-local/parse-stdout.ts new file mode 100644 index 00000000..5d6d4ad3 --- /dev/null +++ b/ui/src/adapters/claude-local/parse-stdout.ts @@ -0,0 +1,103 @@ +import type { TranscriptEntry } from "../types"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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 }]; +} diff --git a/ui/src/adapters/codex-local/build-config.ts b/ui/src/adapters/codex-local/build-config.ts new file mode 100644 index 00000000..45625169 --- /dev/null +++ b/ui/src/adapters/codex-local/build-config.ts @@ -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 { + const env: Record = {}; + 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 { + const ac: Record = {}; + 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; +} diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx new file mode 100644 index 00000000..bfb10121 --- /dev/null +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -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 ( + <> + + isCreate + ? set!({ dangerouslyBypassSandbox: v }) + : mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v) + } + /> + + isCreate + ? set!({ search: v }) + : mark("adapterConfig", "search", v) + } + /> + + ); +} diff --git a/ui/src/adapters/codex-local/index.ts b/ui/src/adapters/codex-local/index.ts new file mode 100644 index 00000000..7eeb4a7c --- /dev/null +++ b/ui/src/adapters/codex-local/index.ts @@ -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, +}; diff --git a/ui/src/adapters/codex-local/parse-stdout.ts b/ui/src/adapters/codex-local/parse-stdout.ts new file mode 100644 index 00000000..721be0f1 --- /dev/null +++ b/ui/src/adapters/codex-local/parse-stdout.ts @@ -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 | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +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 }]; +} diff --git a/ui/src/adapters/http/build-config.ts b/ui/src/adapters/http/build-config.ts new file mode 100644 index 00000000..41c6d346 --- /dev/null +++ b/ui/src/adapters/http/build-config.ts @@ -0,0 +1,9 @@ +import type { CreateConfigValues } from "../../components/AgentConfigForm"; + +export function buildHttpConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.url) ac.url = v.url; + ac.method = "POST"; + ac.timeoutMs = 15000; + return ac; +} diff --git a/ui/src/adapters/http/config-fields.tsx b/ui/src/adapters/http/config-fields.tsx new file mode 100644 index 00000000..a7e33503 --- /dev/null +++ b/ui/src/adapters/http/config-fields.tsx @@ -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 ( + + + isCreate + ? set!({ url: v }) + : mark("adapterConfig", "url", v || undefined) + } + immediate + className={inputClass} + placeholder="https://..." + /> + + ); +} diff --git a/ui/src/adapters/http/index.ts b/ui/src/adapters/http/index.ts new file mode 100644 index 00000000..0ea6229d --- /dev/null +++ b/ui/src/adapters/http/index.ts @@ -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, +}; diff --git a/ui/src/adapters/http/parse-stdout.ts b/ui/src/adapters/http/parse-stdout.ts new file mode 100644 index 00000000..b96642ec --- /dev/null +++ b/ui/src/adapters/http/parse-stdout.ts @@ -0,0 +1,5 @@ +import type { TranscriptEntry } from "../types"; + +export function parseHttpStdoutLine(line: string, ts: string): TranscriptEntry[] { + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts new file mode 100644 index 00000000..b92299e0 --- /dev/null +++ b/ui/src/adapters/index.ts @@ -0,0 +1,8 @@ +export { getUIAdapter } from "./registry"; +export { buildTranscript } from "./transcript"; +export type { + TranscriptEntry, + StdoutLineParser, + UIAdapterModule, + AdapterConfigFieldsProps, +} from "./types"; diff --git a/ui/src/adapters/process/build-config.ts b/ui/src/adapters/process/build-config.ts new file mode 100644 index 00000000..c5b3d567 --- /dev/null +++ b/ui/src/adapters/process/build-config.ts @@ -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 { + const ac: Record = {}; + 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; +} diff --git a/ui/src/adapters/process/config-fields.tsx b/ui/src/adapters/process/config-fields.tsx new file mode 100644 index 00000000..9eff75df --- /dev/null +++ b/ui/src/adapters/process/config-fields.tsx @@ -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 ( + <> + + + isCreate + ? set!({ command: v }) + : mark("adapterConfig", "command", v || undefined) + } + immediate + className={inputClass} + placeholder="e.g. node, python" + /> + + + + isCreate + ? set!({ args: v }) + : mark( + "adapterConfig", + "args", + v ? parseCommaArgs(v) : undefined, + ) + } + immediate + className={inputClass} + placeholder="e.g. script.js, --flag" + /> + + + ); +} diff --git a/ui/src/adapters/process/index.ts b/ui/src/adapters/process/index.ts new file mode 100644 index 00000000..74e6cb38 --- /dev/null +++ b/ui/src/adapters/process/index.ts @@ -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, +}; diff --git a/ui/src/adapters/process/parse-stdout.ts b/ui/src/adapters/process/parse-stdout.ts new file mode 100644 index 00000000..22e564c1 --- /dev/null +++ b/ui/src/adapters/process/parse-stdout.ts @@ -0,0 +1,5 @@ +import type { TranscriptEntry } from "../types"; + +export function parseProcessStdoutLine(line: string, ts: string): TranscriptEntry[] { + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts new file mode 100644 index 00000000..4ae0d0bc --- /dev/null +++ b/ui/src/adapters/registry.ts @@ -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( + [claudeLocalUIAdapter, codexLocalUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]), +); + +export function getUIAdapter(type: string): UIAdapterModule { + return adaptersByType.get(type) ?? processUIAdapter; +} diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts new file mode 100644 index 00000000..33add4d1 --- /dev/null +++ b/ui/src/adapters/transcript.ts @@ -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; +} diff --git a/ui/src/adapters/types.ts b/ui/src/adapters/types.ts new file mode 100644 index 00000000..8b83362b --- /dev/null +++ b/ui/src/adapters/types.ts @@ -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) => void) | null; + /** Edit mode: original adapterConfig from agent */ + config: Record; + /** Edit mode: read effective value */ + eff: (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; + buildAdapterConfig: (values: CreateConfigValues) => Record; +} diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index ee941215..56d511e2 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclip/shared"; import type { Agent } from "@paperclip/shared"; @@ -24,6 +24,8 @@ import { help, adapterLabels, } from "./agent-config-primitives"; +import { getUIAdapter } from "../adapters"; +import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; /* ---- Create mode values ---- */ @@ -251,6 +253,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; const isLocal = adapterType === "claude_local" || adapterType === "codex_local"; + const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type const { data: fetchedModels } = useQuery({ @@ -259,6 +262,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); 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) => props.onChange(patch) : null, + config, + eff: eff as (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 const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false); const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate); @@ -443,130 +459,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {/* Claude-specific: Skip permissions */} - {adapterType === "claude_local" && ( - - isCreate - ? set!({ dangerouslySkipPermissions: v }) - : mark("adapterConfig", "dangerouslySkipPermissions", v) - } - /> - )} - - {/* Codex-specific: Bypass sandbox + Search */} - {adapterType === "codex_local" && ( - <> - - isCreate - ? set!({ dangerouslyBypassSandbox: v }) - : mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v) - } - /> - - isCreate - ? set!({ search: v }) - : mark("adapterConfig", "search", v) - } - /> - - )} - - {/* Process-specific */} - {adapterType === "process" && ( - <> - - - isCreate - ? set!({ command: v }) - : mark("adapterConfig", "command", v || undefined) - } - immediate - className={inputClass} - placeholder="e.g. node, python" - /> - - - - isCreate - ? set!({ args: v }) - : mark( - "adapterConfig", - "args", - v ? parseCommaArgs(v) : undefined, - ) - } - immediate - className={inputClass} - placeholder="e.g. script.js, --flag" - /> - - - )} - - {/* HTTP-specific */} - {adapterType === "http" && ( - - - isCreate - ? set!({ url: v }) - : mark("adapterConfig", "url", v || undefined) - } - immediate - className={inputClass} - placeholder="https://..." - /> - - )} + {/* Adapter-specific fields */} + {/* Advanced adapter section — collapsible in both modes */} {isLocal && ( @@ -630,27 +524,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} {adapterType === "claude_local" && ( - - {isCreate ? ( - set!({ maxTurnsPerRun: Number(e.target.value) })} - /> - ) : ( - mark("adapterConfig", "maxTurnsPerRun", v || 80)} - immediate - className={inputClass} - /> - )} - + )} diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 5cce8abc..c2050193 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -29,28 +29,7 @@ import { defaultCreateValues, type CreateConfigValues, } from "./AgentConfigForm"; - -function parseCommaArgs(value: string): string[] { - return value - .split(",") - .map((item) => item.trim()) - .filter(Boolean); -} - -function parseEnvVars(text: string): Record { - const env: Record = {}; - 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; -} +import { getUIAdapter } from "../adapters"; export function NewAgentDialog() { const { newAgentOpen, closeNewAgent } = useDialog(); @@ -116,34 +95,8 @@ export function NewAgentDialog() { } function buildAdapterConfig() { - const v = configValues; - const ac: Record = {}; - 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; + const adapter = getUIAdapter(configValues.adapterType); + return adapter.buildAdapterConfig(configValues); } function handleSubmit() { diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 91015774..09a4d97a 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -16,6 +16,8 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { cn } from "../lib/utils"; +import { getUIAdapter } from "../adapters"; +import { defaultCreateValues } from "./AgentConfigForm"; import { Building2, Bot, @@ -97,33 +99,17 @@ export function OnboardingWizard() { } function buildAdapterConfig(): Record { - if (adapterType === "claude_local") { - return { - ...(cwd ? { cwd } : {}), - ...(model ? { model } : {}), - timeoutSec: 900, - graceSec: 15, - maxTurnsPerRun: 80, - dangerouslySkipPermissions: true, - }; - } - 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, - }; + const adapter = getUIAdapter(adapterType); + return adapter.buildAdapterConfig({ + ...defaultCreateValues, + adapterType, + cwd, + model, + command, + args, + url, + dangerouslySkipPermissions: adapterType === "claude_local", + }); } async function handleStep1Next() { @@ -594,11 +580,7 @@ export function OnboardingWizard() {

{agentName}

- {adapterType === "claude_local" - ? "Claude Code" - : adapterType === "process" - ? "Shell Command" - : "HTTP Webhook"} + {getUIAdapter(adapterType).label}

diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 86b9b573..1a2d977f 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -12,6 +12,8 @@ import { queryKeys } from "../lib/queryKeys"; import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels } from "../components/agent-config-primitives"; +import { getUIAdapter, buildTranscript } from "../adapters"; +import type { TranscriptEntry } from "../adapters"; import { StatusBadge } from "../components/StatusBadge"; import { EntityRow } from "../components/EntityRow"; 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 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 | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } -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() { const { agentId, runId: urlRunId } = useParams<{ agentId: string; runId?: string }>(); const { selectedCompanyId } = useCompany(); @@ -608,7 +471,7 @@ export function AgentDetail() { {/* RUNS TAB */} - + {/* ISSUES TAB */} @@ -707,7 +570,7 @@ function ConfigurationTab({ /* ---- 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(); if (runs.length === 0) { @@ -785,7 +648,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatR {/* Right: run detail — natural height, page scrolls */} {selectedRun && (
- +
)} @@ -794,7 +657,7 @@ function RunsTab({ runs, companyId, agentId, selectedRunId }: { runs: HeartbeatR /* ---- Run Detail (expanded) ---- */ -function RunDetail({ run }: { run: HeartbeatRun }) { +function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { const queryClient = useQueryClient(); const metrics = runMetrics(run); @@ -939,14 +802,14 @@ function RunDetail({ run }: { run: HeartbeatRun }) { {/* Log viewer */} - + ); } /* ---- Log Viewer ---- */ -function LogViewer({ run }: { run: HeartbeatRun }) { +function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) { const [events, setEvents] = useState([]); const [logLines, setLogLines] = useState>([]); const [loading, setLoading] = useState(true); @@ -1099,7 +962,8 @@ function LogViewer({ run }: { run: HeartbeatRun }) { return asRecord(evt?.payload ?? null); }, [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) { return

Loading run logs...

;