feat: adapter model discovery, reasoning effort, and improved codex formatting
Add dynamic OpenAI model list fetching for codex adapter with caching, async listModels interface, reasoning effort support for both claude and codex adapters, optional timeouts (default to unlimited), wakeCommentId context propagation, and richer codex stdout event parsing/formatting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ export type {
|
||||
AdapterInvocationMeta,
|
||||
AdapterExecutionContext,
|
||||
AdapterSessionCodec,
|
||||
AdapterModel,
|
||||
ServerAdapterModule,
|
||||
TranscriptEntry,
|
||||
StdoutLineParser,
|
||||
|
||||
@@ -201,15 +201,18 @@ export async function runChildProcess(
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, Math.max(1, opts.timeoutSec) * 1000);
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
@@ -228,7 +231,7 @@ export async function runChildProcess(
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
@@ -240,7 +243,7 @@ export async function runChildProcess(
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
|
||||
@@ -77,12 +77,18 @@ export interface AdapterExecutionContext {
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface AdapterModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ServerAdapterModule {
|
||||
type: string;
|
||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||
sessionCodec?: AdapterSessionCodec;
|
||||
supportsLocalAgentJwt?: boolean;
|
||||
models?: { id: string; label: string }[];
|
||||
models?: AdapterModel[];
|
||||
listModels?: () => Promise<AdapterModel[]>;
|
||||
agentConfigurationDoc?: string;
|
||||
}
|
||||
|
||||
@@ -122,6 +128,7 @@ export interface CreateConfigValues {
|
||||
cwd: string;
|
||||
promptTemplate: string;
|
||||
model: string;
|
||||
thinkingEffort: string;
|
||||
dangerouslySkipPermissions: boolean;
|
||||
search: boolean;
|
||||
dangerouslyBypassSandbox: boolean;
|
||||
|
||||
@@ -14,6 +14,7 @@ Adapter: claude_local
|
||||
Core fields:
|
||||
- cwd (string, required): absolute working directory for the agent process
|
||||
- model (string, optional): Claude model id
|
||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- bootstrapPromptTemplate (string, optional): first-run prompt template
|
||||
- maxTurnsPerRun (number, optional): max turns for one run
|
||||
|
||||
@@ -57,6 +57,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
||||
const command = asString(config.command, "claude");
|
||||
const model = asString(config.model, "");
|
||||
const effort = asString(config.effort, "");
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
||||
|
||||
@@ -75,6 +76,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
@@ -92,6 +97,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
@@ -110,7 +118,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
@@ -148,6 +156,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||
if (model) args.push("--model", model);
|
||||
if (effort) args.push("--effort", effort);
|
||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||
args.push("--add-dir", skillsDir);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
|
||||
@@ -56,6 +56,7 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
|
||||
@@ -1,5 +1,144 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
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 asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
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 printItemStarted(item: Record<string, unknown>): boolean {
|
||||
const itemType = asString(item.type);
|
||||
if (itemType === "command_execution") {
|
||||
const command = asString(item.command);
|
||||
console.log(pc.yellow("tool_call: command_execution"));
|
||||
if (command) console.log(pc.gray(command));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
const name = asString(item.name, "unknown");
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (item.input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(item.input)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function printItemCompleted(item: Record<string, unknown>): boolean {
|
||||
const itemType = asString(item.type);
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
const name = asString(item.name, "unknown");
|
||||
console.log(pc.yellow(`tool_call: ${name}`));
|
||||
if (item.input !== undefined) {
|
||||
try {
|
||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
||||
} catch {
|
||||
console.log(pc.gray(String(item.input)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "command_execution") {
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
const isError =
|
||||
(exitCode !== null && exitCode !== 0) ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
|
||||
const summaryParts = [
|
||||
"tool_result: command_execution",
|
||||
command ? `command="${command}"` : "",
|
||||
status ? `status=${status}` : "",
|
||||
exitCode !== null ? `exit_code=${exitCode}` : "",
|
||||
].filter(Boolean);
|
||||
console.log((isError ? pc.red : pc.cyan)(summaryParts.join(" ")));
|
||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "file_change") {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const entries = changes
|
||||
.map((changeRaw) => asRecord(changeRaw))
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
const preview = entries.length > 0 ? entries.slice(0, 6).join(", ") : "none";
|
||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
||||
console.log(pc.cyan(`file_change: ${preview}${more}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "error") {
|
||||
const message = errorText(item.message ?? item.error ?? item);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (itemType === "tool_result") {
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
const text = asString(item.content) || asString(item.result) || asString(item.output);
|
||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
||||
if (text) console.log((isError ? pc.red : pc.gray)(text));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function printCodexStreamEvent(raw: string, _debug: boolean): void {
|
||||
const line = raw.trim();
|
||||
if (!line) return;
|
||||
@@ -12,43 +151,77 @@ export function printCodexStreamEvent(raw: string, _debug: boolean): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
const type = asString(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})` : ""}`));
|
||||
const threadId = asString(parsed.thread_id);
|
||||
const model = asString(parsed.model);
|
||||
const details = [threadId ? `session: ${threadId}` : "", model ? `model: ${model}` : ""].filter(Boolean).join(", ");
|
||||
console.log(pc.blue(`Codex thread started${details ? ` (${details})` : ""}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "item.completed") {
|
||||
const item =
|
||||
typeof parsed.item === "object" && parsed.item !== null && !Array.isArray(parsed.item)
|
||||
? (parsed.item as Record<string, unknown>)
|
||||
: null;
|
||||
if (type === "turn.started") {
|
||||
console.log(pc.blue("turn started"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "item.started" || 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) 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}`));
|
||||
const handled =
|
||||
type === "item.started"
|
||||
? printItemStarted(item)
|
||||
: printItemCompleted(item);
|
||||
if (!handled) {
|
||||
const itemType = asString(item.type, "unknown");
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
console.log(pc.gray(`${type}: ${itemType}${meta ? ` (${meta})` : ""}`));
|
||||
}
|
||||
} else {
|
||||
console.log(pc.gray(type));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.completed") {
|
||||
const usage =
|
||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
||||
? (parsed.usage as Record<string, unknown>)
|
||||
: {};
|
||||
const input = Number(usage.input_tokens ?? 0);
|
||||
const output = Number(usage.output_tokens ?? 0);
|
||||
const cached = Number(usage.cached_input_tokens ?? 0);
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens);
|
||||
const output = asNumber(usage?.output_tokens);
|
||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const cost = asNumber(parsed.total_cost_usd);
|
||||
const isError = parsed.is_error === true;
|
||||
const subtype = asString(parsed.subtype);
|
||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
||||
|
||||
console.log(
|
||||
pc.blue(`tokens: in=${input} out=${output} cached=${cached}`),
|
||||
pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`),
|
||||
);
|
||||
if (subtype || isError || errors.length > 0) {
|
||||
console.log(
|
||||
pc.red(`result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`),
|
||||
);
|
||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const input = asNumber(usage?.input_tokens);
|
||||
const output = asNumber(usage?.output_tokens);
|
||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
console.log(pc.red(`turn failed${message ? `: ${message}` : ""}`));
|
||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
if (message) console.log(pc.red(`error: ${message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,14 @@ export const type = "codex_local";
|
||||
export const label = "Codex (local)";
|
||||
|
||||
export const models = [
|
||||
{ id: "gpt-5.3-codex", label: "gpt-5.3-codex" },
|
||||
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
|
||||
{ id: "gpt-5", label: "gpt-5" },
|
||||
{ id: "o4-mini", label: "o4-mini" },
|
||||
{ id: "o3", label: "o3" },
|
||||
{ id: "o4-mini", label: "o4-mini" },
|
||||
{ id: "gpt-5-mini", label: "gpt-5-mini" },
|
||||
{ id: "gpt-5-nano", label: "gpt-5-nano" },
|
||||
{ id: "o3-mini", label: "o3-mini" },
|
||||
{ id: "codex-mini-latest", label: "Codex Mini" },
|
||||
];
|
||||
|
||||
@@ -15,6 +20,7 @@ Adapter: codex_local
|
||||
Core fields:
|
||||
- cwd (string, required): absolute working directory for the agent process
|
||||
- model (string, optional): Codex model id
|
||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
||||
- promptTemplate (string, optional): run prompt template
|
||||
- bootstrapPromptTemplate (string, optional): first-run prompt template
|
||||
- search (boolean, optional): run codex with --search
|
||||
@@ -30,4 +36,5 @@ Operational fields:
|
||||
Notes:
|
||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
||||
- Paperclip auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$paperclip" and related skills.
|
||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
||||
`;
|
||||
|
||||
@@ -98,6 +98,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
const modelReasoningEffort = asString(
|
||||
config.modelReasoningEffort,
|
||||
asString(config.reasoningEffort, ""),
|
||||
);
|
||||
const search = asBoolean(config.search, false);
|
||||
const bypass = asBoolean(config.dangerouslyBypassApprovalsAndSandbox, false);
|
||||
|
||||
@@ -117,6 +121,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
@@ -134,6 +142,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
@@ -152,7 +163,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 1800);
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
@@ -189,6 +200,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (search) args.unshift("--search");
|
||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||
if (model) args.push("--model", model);
|
||||
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
|
||||
else args.push("-");
|
||||
|
||||
@@ -56,6 +56,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
|
||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
||||
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
|
||||
if (v.model) ac.model = v.model;
|
||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
||||
ac.timeoutSec = 0;
|
||||
ac.graceSec = 15;
|
||||
const env = parseEnvBindings(v.envBindings);
|
||||
|
||||
@@ -13,61 +13,240 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
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 stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === null || value === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommandExecutionItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const id = asString(item.id);
|
||||
const command = asString(item.command);
|
||||
const status = asString(item.status);
|
||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
||||
|
||||
if (phase === "started") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: "command_execution",
|
||||
input: {
|
||||
id,
|
||||
command,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (command) lines.push(`command: ${command}`);
|
||||
if (status) lines.push(`status: ${status}`);
|
||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
||||
if (output) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(output);
|
||||
}
|
||||
|
||||
const isError =
|
||||
(exitCode !== null && exitCode !== 0) ||
|
||||
status === "failed" ||
|
||||
status === "errored" ||
|
||||
status === "error" ||
|
||||
status === "cancelled";
|
||||
|
||||
return [{
|
||||
kind: "tool_result",
|
||||
ts,
|
||||
toolUseId: id || command || "command_execution",
|
||||
content: lines.join("\n").trim() || "command completed",
|
||||
isError,
|
||||
}];
|
||||
}
|
||||
|
||||
function parseFileChangeItem(item: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const entries = changes
|
||||
.map((changeRaw) => asRecord(changeRaw))
|
||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||
.map((change) => {
|
||||
const kind = asString(change.kind, "update");
|
||||
const path = asString(change.path, "unknown");
|
||||
return `${kind} ${path}`;
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return [{ kind: "system", ts, text: "file changes applied" }];
|
||||
}
|
||||
|
||||
const preview = entries.slice(0, 6).join(", ");
|
||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
||||
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
||||
}
|
||||
|
||||
function parseCodexItem(
|
||||
item: Record<string, unknown>,
|
||||
ts: string,
|
||||
phase: "started" | "completed",
|
||||
): TranscriptEntry[] {
|
||||
const itemType = asString(item.type);
|
||||
|
||||
if (itemType === "agent_message") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "assistant", ts, text }];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (itemType === "reasoning") {
|
||||
const text = asString(item.text);
|
||||
if (text) return [{ kind: "thinking", ts, text }];
|
||||
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
||||
}
|
||||
|
||||
if (itemType === "command_execution") {
|
||||
return parseCommandExecutionItem(item, ts, phase);
|
||||
}
|
||||
|
||||
if (itemType === "file_change" && phase === "completed") {
|
||||
return parseFileChangeItem(item, ts);
|
||||
}
|
||||
|
||||
if (itemType === "tool_use") {
|
||||
return [{
|
||||
kind: "tool_call",
|
||||
ts,
|
||||
name: asString(item.name, "unknown"),
|
||||
input: item.input ?? {},
|
||||
}];
|
||||
}
|
||||
|
||||
if (itemType === "tool_result" && phase === "completed") {
|
||||
const toolUseId = asString(item.tool_use_id, asString(item.id));
|
||||
const content =
|
||||
asString(item.content) ||
|
||||
asString(item.output) ||
|
||||
asString(item.result) ||
|
||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
||||
const isError = item.is_error === true || asString(item.status) === "error";
|
||||
return [{ kind: "tool_result", ts, toolUseId, content, isError }];
|
||||
}
|
||||
|
||||
if (itemType === "error" && phase === "completed") {
|
||||
const text = errorText(item.message ?? item.error ?? item);
|
||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
||||
}
|
||||
|
||||
const id = asString(item.id);
|
||||
const status = asString(item.status);
|
||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
||||
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }];
|
||||
}
|
||||
|
||||
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 : "";
|
||||
const type = asString(parsed.type);
|
||||
|
||||
if (type === "thread.started") {
|
||||
const threadId = typeof parsed.thread_id === "string" ? parsed.thread_id : "";
|
||||
const threadId = asString(parsed.thread_id);
|
||||
return [{
|
||||
kind: "init",
|
||||
ts,
|
||||
model: "codex",
|
||||
model: asString(parsed.model, "codex"),
|
||||
sessionId: threadId,
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "item.completed") {
|
||||
if (type === "turn.started") {
|
||||
return [{ kind: "system", ts, text: "turn started" }];
|
||||
}
|
||||
|
||||
if (type === "item.started" || 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 (!item) return [{ kind: "system", ts, text: type.replace(".", " ") }];
|
||||
return parseCodexItem(item, ts, type === "item.started" ? "started" : "completed");
|
||||
}
|
||||
|
||||
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;
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens);
|
||||
const outputTokens = asNumber(usage?.output_tokens);
|
||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: "",
|
||||
text: asString(parsed.result),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: 0,
|
||||
subtype: "",
|
||||
isError: false,
|
||||
errors: [],
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype),
|
||||
isError: parsed.is_error === true,
|
||||
errors: Array.isArray(parsed.errors)
|
||||
? parsed.errors.map(errorText).filter(Boolean)
|
||||
: [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "turn.failed") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens);
|
||||
const outputTokens = asNumber(usage?.output_tokens);
|
||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
||||
const message = errorText(parsed.error ?? parsed.message);
|
||||
return [{
|
||||
kind: "result",
|
||||
ts,
|
||||
text: asString(parsed.result),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd),
|
||||
subtype: asString(parsed.subtype, "turn.failed"),
|
||||
isError: true,
|
||||
errors: message ? [message] : [],
|
||||
}];
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
||||
return [{ kind: "stderr", ts, text: message || line }];
|
||||
}
|
||||
|
||||
return [{ kind: "stdout", ts, text: line }];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user