Extract adapter registry across CLI, server, and UI
Refactor monolithic heartbeat service, AgentConfigForm, and CLI heartbeat-run into a proper adapter registry pattern. Each adapter type (process, claude-local, codex-local, http) gets its own module with server-side execution logic, CLI invocation, and UI config form. Significantly reduces file sizes and enables adding new adapters without touching core code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
40
ui/src/adapters/claude-local/build-config.ts
Normal file
40
ui/src/adapters/claude-local/build-config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvVars(v.envVars);
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
78
ui/src/adapters/claude-local/config-fields.tsx
Normal file
78
ui/src/adapters/claude-local/config-fields.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
ToggleField,
|
||||
DraftInput,
|
||||
DraftNumberInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
export function ClaudeLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<ToggleField
|
||||
label="Skip permissions"
|
||||
hint={help.dangerouslySkipPermissions}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslySkipPermissions
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslySkipPermissions",
|
||||
config.dangerouslySkipPermissions !== false,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslySkipPermissions: v })
|
||||
: mark("adapterConfig", "dangerouslySkipPermissions", v)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Max turns — only shown in advanced section context, rendered here for availability */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaudeLocalAdvancedFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
||||
{isCreate ? (
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={values!.maxTurnsPerRun}
|
||||
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
|
||||
/>
|
||||
) : (
|
||||
<DraftNumberInput
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"maxTurnsPerRun",
|
||||
Number(config.maxTurnsPerRun ?? 80),
|
||||
)}
|
||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/claude-local/index.ts
Normal file
12
ui/src/adapters/claude-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseClaudeStdoutLine } from "./parse-stdout";
|
||||
import { ClaudeLocalConfigFields } from "./config-fields";
|
||||
import { buildClaudeLocalConfig } from "./build-config";
|
||||
|
||||
export const claudeLocalUIAdapter: UIAdapterModule = {
|
||||
type: "claude_local",
|
||||
label: "Claude Code (local)",
|
||||
parseStdoutLine: parseClaudeStdoutLine,
|
||||
ConfigFields: ClaudeLocalConfigFields,
|
||||
buildAdapterConfig: buildClaudeLocalConfig,
|
||||
};
|
||||
103
ui/src/adapters/claude-local/parse-stdout.ts
Normal file
103
ui/src/adapters/claude-local/parse-stdout.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { TranscriptEntry } from "../types";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
if (type === "system" && parsed.subtype === "init") {
|
||||
return [
|
||||
{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
||||
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "assistant") {
|
||||
const message = asRecord(parsed.message) ?? {};
|
||||
const content = Array.isArray(message.content) ? message.content : [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
for (const blockRaw of content) {
|
||||
const block = asRecord(blockRaw);
|
||||
if (!block) continue;
|
||||
const blockType = typeof block.type === "string" ? block.type : "";
|
||||
if (blockType === "text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
if (text) entries.push({ kind: "assistant", ts, text });
|
||||
} else if (blockType === "tool_use") {
|
||||
entries.push({
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: typeof block.name === "string" ? block.name : "unknown",
|
||||
input: block.input ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
if (type === "result") {
|
||||
const usage = asRecord(parsed.usage) ?? {};
|
||||
const inputTokens = asNumber(usage.input_tokens);
|
||||
const outputTokens = asNumber(usage.output_tokens);
|
||||
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
||||
const costUsd = asNumber(parsed.total_cost_usd);
|
||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
||||
const isError = parsed.is_error === true;
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||
const text = typeof parsed.result === "string" ? parsed.result : "";
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd,
|
||||
subtype,
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
40
ui/src/adapters/codex-local/build-config.ts
Normal file
40
ui/src/adapters/codex-local/build-config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvVars(v.envVars);
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
ac.search = v.search;
|
||||
ac.dangerouslyBypassApprovalsAndSandbox = v.dangerouslyBypassSandbox;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
||||
return ac;
|
||||
}
|
||||
51
ui/src/adapters/codex-local/config-fields.tsx
Normal file
51
ui/src/adapters/codex-local/config-fields.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
ToggleField,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
export function CodexLocalConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
config.dangerouslyBypassApprovalsAndSandbox !== false,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? values!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/codex-local/index.ts
Normal file
12
ui/src/adapters/codex-local/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseCodexStdoutLine } from "./parse-stdout";
|
||||
import { CodexLocalConfigFields } from "./config-fields";
|
||||
import { buildCodexLocalConfig } from "./build-config";
|
||||
|
||||
export const codexLocalUIAdapter: UIAdapterModule = {
|
||||
type: "codex_local",
|
||||
label: "Codex (local)",
|
||||
parseStdoutLine: parseCodexStdoutLine,
|
||||
ConfigFields: CodexLocalConfigFields,
|
||||
buildAdapterConfig: buildCodexLocalConfig,
|
||||
};
|
||||
73
ui/src/adapters/codex-local/parse-stdout.ts
Normal file
73
ui/src/adapters/codex-local/parse-stdout.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { TranscriptEntry } from "../types";
|
||||
|
||||
function safeJsonParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
const parsed = asRecord(safeJsonParse(line));
|
||||
if (!parsed) {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = typeof parsed.thread_id === "string" ? parsed.thread_id : "";
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: "codex",
|
||||
sessionId: threadId,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "item.completed") {
|
||||
const item = asRecord(parsed.item);
|
||||
if (item) {
|
||||
const itemType = typeof item.type === "string" ? item.type : "";
|
||||
if (itemType === "agent_message") {
|
||||
const text = typeof item.text === "string" ? item.text : "";
|
||||
if (text) return [{ kind: "assistant", ts, text }];
|
||||
}
|
||||
if (itemType === "tool_use") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: typeof item.name === "string" ? item.name : "unknown",
|
||||
input: item.input ?? {},
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usage = asRecord(parsed.usage) ?? {};
|
||||
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
||||
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
||||
const cachedTokens = typeof usage.cached_input_tokens === "number" ? usage.cached_input_tokens : 0;
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: 0,
|
||||
subtype: "",
|
||||
isError: false,
|
||||
errors: [],
|
||||
}];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
9
ui/src/adapters/http/build-config.ts
Normal file
9
ui/src/adapters/http/build-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||
|
||||
export function buildHttpConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.url) ac.url = v.url;
|
||||
ac.method = "POST";
|
||||
ac.timeoutMs = 15000;
|
||||
return ac;
|
||||
}
|
||||
38
ui/src/adapters/http/config-fields.tsx
Normal file
38
ui/src/adapters/http/config-fields.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
export function HttpConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<Field label="Webhook URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/http/index.ts
Normal file
12
ui/src/adapters/http/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseHttpStdoutLine } from "./parse-stdout";
|
||||
import { HttpConfigFields } from "./config-fields";
|
||||
import { buildHttpConfig } from "./build-config";
|
||||
|
||||
export const httpUIAdapter: UIAdapterModule = {
|
||||
type: "http",
|
||||
label: "HTTP Webhook",
|
||||
parseStdoutLine: parseHttpStdoutLine,
|
||||
ConfigFields: HttpConfigFields,
|
||||
buildAdapterConfig: buildHttpConfig,
|
||||
};
|
||||
5
ui/src/adapters/http/parse-stdout.ts
Normal file
5
ui/src/adapters/http/parse-stdout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { TranscriptEntry } from "../types";
|
||||
|
||||
export function parseHttpStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
8
ui/src/adapters/index.ts
Normal file
8
ui/src/adapters/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { getUIAdapter } from "./registry";
|
||||
export { buildTranscript } from "./transcript";
|
||||
export type {
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
UIAdapterModule,
|
||||
AdapterConfigFieldsProps,
|
||||
} from "./types";
|
||||
18
ui/src/adapters/process/build-config.ts
Normal file
18
ui/src/adapters/process/build-config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CreateConfigValues } from "../../components/AgentConfigForm";
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildProcessConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||
const ac: Record<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
if (v.command) ac.command = v.command;
|
||||
if (v.args) ac.args = parseCommaArgs(v.args);
|
||||
return ac;
|
||||
}
|
||||
77
ui/src/adapters/process/config-fields.tsx
Normal file
77
ui/src/adapters/process/config-fields.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { AdapterConfigFieldsProps } from "../types";
|
||||
import {
|
||||
Field,
|
||||
DraftInput,
|
||||
help,
|
||||
} from "../../components/agent-config-primitives";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||
|
||||
function formatArgList(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.join(", ");
|
||||
}
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseCommaArgs(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function ProcessConfigFields({
|
||||
isCreate,
|
||||
values,
|
||||
set,
|
||||
config,
|
||||
eff,
|
||||
mark,
|
||||
}: AdapterConfigFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field label="Command" hint={help.command}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.command
|
||||
: eff("adapterConfig", "command", String(config.command ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", "command", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="e.g. node, python"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Args (comma-separated)" hint={help.args}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? values!.args
|
||||
: eff("adapterConfig", "args", formatArgList(config.args))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ args: v })
|
||||
: mark(
|
||||
"adapterConfig",
|
||||
"args",
|
||||
v ? parseCommaArgs(v) : undefined,
|
||||
)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="e.g. script.js, --flag"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/src/adapters/process/index.ts
Normal file
12
ui/src/adapters/process/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { UIAdapterModule } from "../types";
|
||||
import { parseProcessStdoutLine } from "./parse-stdout";
|
||||
import { ProcessConfigFields } from "./config-fields";
|
||||
import { buildProcessConfig } from "./build-config";
|
||||
|
||||
export const processUIAdapter: UIAdapterModule = {
|
||||
type: "process",
|
||||
label: "Shell Process",
|
||||
parseStdoutLine: parseProcessStdoutLine,
|
||||
ConfigFields: ProcessConfigFields,
|
||||
buildAdapterConfig: buildProcessConfig,
|
||||
};
|
||||
5
ui/src/adapters/process/parse-stdout.ts
Normal file
5
ui/src/adapters/process/parse-stdout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { TranscriptEntry } from "../types";
|
||||
|
||||
export function parseProcessStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
13
ui/src/adapters/registry.ts
Normal file
13
ui/src/adapters/registry.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { UIAdapterModule } from "./types";
|
||||
import { claudeLocalUIAdapter } from "./claude-local";
|
||||
import { codexLocalUIAdapter } from "./codex-local";
|
||||
import { processUIAdapter } from "./process";
|
||||
import { httpUIAdapter } from "./http";
|
||||
|
||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||
[claudeLocalUIAdapter, codexLocalUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
||||
);
|
||||
|
||||
export function getUIAdapter(type: string): UIAdapterModule {
|
||||
return adaptersByType.get(type) ?? processUIAdapter;
|
||||
}
|
||||
36
ui/src/adapters/transcript.ts
Normal file
36
ui/src/adapters/transcript.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
||||
|
||||
type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
||||
|
||||
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
|
||||
const entries: TranscriptEntry[] = [];
|
||||
let stdoutBuffer = "";
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.stream === "stderr") {
|
||||
entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk });
|
||||
continue;
|
||||
}
|
||||
if (chunk.stream === "system") {
|
||||
entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk });
|
||||
continue;
|
||||
}
|
||||
|
||||
const combined = stdoutBuffer + chunk.chunk;
|
||||
const lines = combined.split(/\r?\n/);
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
entries.push(...parser(trimmed, chunk.ts));
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = stdoutBuffer.trim();
|
||||
if (trailing) {
|
||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
||||
entries.push(...parser(trailing, ts));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
39
ui/src/adapters/types.ts
Normal file
39
ui/src/adapters/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { CreateConfigValues } from "../components/AgentConfigForm";
|
||||
|
||||
export type TranscriptEntry =
|
||||
| { kind: "assistant"; ts: string; text: string }
|
||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
||||
| { kind: "stderr"; ts: string; text: string }
|
||||
| { kind: "system"; ts: string; text: string }
|
||||
| { kind: "stdout"; ts: string; text: string };
|
||||
|
||||
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
|
||||
|
||||
export interface AdapterConfigFieldsProps {
|
||||
mode: "create" | "edit";
|
||||
isCreate: boolean;
|
||||
adapterType: string;
|
||||
/** Create mode: raw form values */
|
||||
values: CreateConfigValues | null;
|
||||
/** Create mode: setter for form values */
|
||||
set: ((patch: Partial<CreateConfigValues>) => void) | null;
|
||||
/** Edit mode: original adapterConfig from agent */
|
||||
config: Record<string, unknown>;
|
||||
/** Edit mode: read effective value */
|
||||
eff: <T>(group: "adapterConfig", field: string, original: T) => T;
|
||||
/** Edit mode: mark field dirty */
|
||||
mark: (group: "adapterConfig", field: string, value: unknown) => void;
|
||||
/** Available models for dropdowns */
|
||||
models: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface UIAdapterModule {
|
||||
type: string;
|
||||
label: string;
|
||||
parseStdoutLine: StdoutLineParser;
|
||||
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
|
||||
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { 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<CreateConfigValues>) => props.onChange(patch) : null,
|
||||
config,
|
||||
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
|
||||
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
|
||||
models,
|
||||
};
|
||||
|
||||
// Section toggle state — advanced always starts collapsed
|
||||
const [adapterAdvancedOpen, setAdapterAdvancedOpen] = useState(false);
|
||||
const [heartbeatOpen, setHeartbeatOpen] = useState(!isCreate);
|
||||
@@ -443,130 +459,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Claude-specific: Skip permissions */}
|
||||
{adapterType === "claude_local" && (
|
||||
<ToggleField
|
||||
label="Skip permissions"
|
||||
hint={help.dangerouslySkipPermissions}
|
||||
checked={
|
||||
isCreate
|
||||
? val!.dangerouslySkipPermissions
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslySkipPermissions",
|
||||
config.dangerouslySkipPermissions !== false,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslySkipPermissions: v })
|
||||
: mark("adapterConfig", "dangerouslySkipPermissions", v)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex-specific: Bypass sandbox + Search */}
|
||||
{adapterType === "codex_local" && (
|
||||
<>
|
||||
<ToggleField
|
||||
label="Bypass sandbox"
|
||||
hint={help.dangerouslyBypassSandbox}
|
||||
checked={
|
||||
isCreate
|
||||
? val!.dangerouslyBypassSandbox
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"dangerouslyBypassApprovalsAndSandbox",
|
||||
config.dangerouslyBypassApprovalsAndSandbox !== false,
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ dangerouslyBypassSandbox: v })
|
||||
: mark("adapterConfig", "dangerouslyBypassApprovalsAndSandbox", v)
|
||||
}
|
||||
/>
|
||||
<ToggleField
|
||||
label="Enable search"
|
||||
hint={help.search}
|
||||
checked={
|
||||
isCreate
|
||||
? val!.search
|
||||
: eff("adapterConfig", "search", !!config.search)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ search: v })
|
||||
: mark("adapterConfig", "search", v)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Process-specific */}
|
||||
{adapterType === "process" && (
|
||||
<>
|
||||
<Field label="Command" hint={help.command}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? val!.command
|
||||
: eff("adapterConfig", "command", String(config.command ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ command: v })
|
||||
: mark("adapterConfig", "command", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="e.g. node, python"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Args (comma-separated)" hint={help.args}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? val!.args
|
||||
: eff("adapterConfig", "args", formatArgList(config.args))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ args: v })
|
||||
: mark(
|
||||
"adapterConfig",
|
||||
"args",
|
||||
v ? parseCommaArgs(v) : undefined,
|
||||
)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="e.g. script.js, --flag"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* HTTP-specific */}
|
||||
{adapterType === "http" && (
|
||||
<Field label="Webhook URL" hint={help.webhookUrl}>
|
||||
<DraftInput
|
||||
value={
|
||||
isCreate
|
||||
? val!.url
|
||||
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||
}
|
||||
onCommit={(v) =>
|
||||
isCreate
|
||||
? set!({ url: v })
|
||||
: mark("adapterConfig", "url", v || undefined)
|
||||
}
|
||||
immediate
|
||||
className={inputClass}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{/* Adapter-specific fields */}
|
||||
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||
|
||||
{/* Advanced adapter section — collapsible in both modes */}
|
||||
{isLocal && (
|
||||
@@ -630,27 +524,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
)}
|
||||
</Field>
|
||||
{adapterType === "claude_local" && (
|
||||
<Field label="Max turns per run" hint={help.maxTurnsPerRun}>
|
||||
{isCreate ? (
|
||||
<input
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={val!.maxTurnsPerRun}
|
||||
onChange={(e) => set!({ maxTurnsPerRun: Number(e.target.value) })}
|
||||
/>
|
||||
) : (
|
||||
<DraftNumberInput
|
||||
value={eff(
|
||||
"adapterConfig",
|
||||
"maxTurnsPerRun",
|
||||
Number(config.maxTurnsPerRun ?? 80),
|
||||
)}
|
||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 80)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
|
||||
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
||||
|
||||
@@ -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<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const valueAtKey = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = valueAtKey;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
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<string, unknown> = {};
|
||||
if (v.cwd) ac.cwd = v.cwd;
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvVars(v.envVars);
|
||||
if (Object.keys(env).length > 0) ac.env = env;
|
||||
|
||||
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() {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{agentName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{adapterType === "claude_local"
|
||||
? "Claude Code"
|
||||
: adapterType === "process"
|
||||
? "Shell Command"
|
||||
: "HTTP Webhook"}
|
||||
{getUIAdapter(adapterType).label}
|
||||
</p>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-green-500 shrink-0" />
|
||||
|
||||
@@ -12,6 +12,8 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
import { 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<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function errorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = asRecord(value);
|
||||
if (!rec) return "";
|
||||
const msg =
|
||||
(typeof rec.message === "string" && rec.message) ||
|
||||
(typeof rec.error === "string" && rec.error) ||
|
||||
(typeof rec.code === "string" && rec.code) ||
|
||||
"";
|
||||
if (msg) return msg;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function 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 */}
|
||||
<TabsContent value="runs" className="mt-4">
|
||||
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} />
|
||||
<RunsTab runs={heartbeats ?? []} companyId={selectedCompanyId!} agentId={agentId!} selectedRunId={urlRunId ?? null} adapterType={agent.adapterType} />
|
||||
</TabsContent>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="flex-1 min-w-0 pl-4">
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} />
|
||||
<RunDetail key={selectedRun.id} run={selectedRun} adapterType={adapterType} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 }) {
|
||||
<Separator />
|
||||
|
||||
{/* Log viewer */}
|
||||
<LogViewer run={run} />
|
||||
<LogViewer run={run} adapterType={adapterType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Log Viewer ---- */
|
||||
|
||||
function LogViewer({ run }: { run: HeartbeatRun }) {
|
||||
function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: string }) {
|
||||
const [events, setEvents] = useState<HeartbeatRunEvent[]>([]);
|
||||
const [logLines, setLogLines] = useState<Array<{ ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }>>([]);
|
||||
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 <p className="text-xs text-muted-foreground">Loading run logs...</p>;
|
||||
|
||||
Reference in New Issue
Block a user