Improve codex-local adapter: skill injection, stdin piping, and error parsing
Codex adapter now auto-injects Paperclip skills into ~/.codex/skills, pipes prompts via stdin instead of passing as CLI args, filters out noisy rollout stderr warnings, and extracts error/turn.failed messages from JSONL output. Also broadens stale session detection for rollout path errors. Claude-local adapter gets the same template vars (agentId, companyId, runId) that codex-local already had. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
}
|
}
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
const prompt = renderTemplate(template, {
|
const prompt = renderTemplate(template, {
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
runId,
|
||||||
company: { id: agent.companyId },
|
company: { id: agent.companyId },
|
||||||
agent,
|
agent,
|
||||||
run: { id: runId, source: "on_demand" },
|
run: { id: runId, source: "on_demand" },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const type = "codex_local";
|
|||||||
export const label = "Codex (local)";
|
export const label = "Codex (local)";
|
||||||
|
|
||||||
export const models = [
|
export const models = [
|
||||||
|
{ id: "gpt-5", label: "gpt-5" },
|
||||||
{ id: "o4-mini", label: "o4-mini" },
|
{ id: "o4-mini", label: "o4-mini" },
|
||||||
{ id: "o3", label: "o3" },
|
{ id: "o3", label: "o3" },
|
||||||
{ id: "codex-mini-latest", label: "Codex Mini" },
|
{ id: "codex-mini-latest", label: "Codex Mini" },
|
||||||
@@ -25,4 +26,8 @@ Core fields:
|
|||||||
Operational fields:
|
Operational fields:
|
||||||
- timeoutSec (number, optional): run timeout in seconds
|
- timeoutSec (number, optional): run timeout in seconds
|
||||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
- graceSec (number, optional): SIGTERM grace period in seconds
|
||||||
|
|
||||||
|
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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
||||||
import {
|
import {
|
||||||
asString,
|
asString,
|
||||||
@@ -16,6 +19,75 @@ import {
|
|||||||
} from "@paperclip/adapter-utils/server-utils";
|
} from "@paperclip/adapter-utils/server-utils";
|
||||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||||
|
|
||||||
|
const PAPERCLIP_SKILLS_DIR = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../../../../skills",
|
||||||
|
);
|
||||||
|
const CODEX_ROLLOUT_NOISE_RE =
|
||||||
|
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||||
|
|
||||||
|
function stripCodexRolloutNoise(text: string): string {
|
||||||
|
const parts = text.split(/\r?\n/);
|
||||||
|
const kept: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
kept.push(part);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||||
|
kept.push(part);
|
||||||
|
}
|
||||||
|
return kept.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyLine(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codexHomeDir(): string {
|
||||||
|
const fromEnv = process.env.CODEX_HOME;
|
||||||
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||||
|
return path.join(os.homedir(), ".codex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||||
|
const sourceExists = await fs
|
||||||
|
.stat(PAPERCLIP_SKILLS_DIR)
|
||||||
|
.then((stats) => stats.isDirectory())
|
||||||
|
.catch(() => false);
|
||||||
|
if (!sourceExists) return;
|
||||||
|
|
||||||
|
const skillsHome = path.join(codexHomeDir(), "skills");
|
||||||
|
await fs.mkdir(skillsHome, { recursive: true });
|
||||||
|
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const source = path.join(PAPERCLIP_SKILLS_DIR, entry.name);
|
||||||
|
const target = path.join(skillsHome, entry.name);
|
||||||
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
if (existing) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.symlink(source, target);
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
await onLog(
|
||||||
|
"stderr",
|
||||||
|
`[paperclip] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||||
|
|
||||||
@@ -31,6 +103,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
const cwd = asString(config.cwd, process.cwd());
|
||||||
await ensureAbsoluteDirectory(cwd);
|
await ensureAbsoluteDirectory(cwd);
|
||||||
|
await ensureCodexSkillsInjected(onLog);
|
||||||
const envConfig = parseObject(config.env);
|
const envConfig = parseObject(config.env);
|
||||||
const hasExplicitApiKey =
|
const hasExplicitApiKey =
|
||||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||||
@@ -102,6 +175,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
}
|
}
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
const prompt = renderTemplate(template, {
|
const prompt = renderTemplate(template, {
|
||||||
|
agentId: agent.id,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
runId,
|
||||||
company: { id: agent.companyId },
|
company: { id: agent.companyId },
|
||||||
agent,
|
agent,
|
||||||
run: { id: runId, source: "on_demand" },
|
run: { id: runId, source: "on_demand" },
|
||||||
@@ -114,8 +190,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||||
if (model) args.push("--model", model);
|
if (model) args.push("--model", model);
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
if (resumeSessionId) args.push("resume", resumeSessionId, prompt);
|
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
|
||||||
else args.push(prompt);
|
else args.push("-");
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,7 +203,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
commandArgs: args.map((value, idx) => {
|
commandArgs: args.map((value, idx) => {
|
||||||
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
||||||
return value;
|
return value;
|
||||||
}),
|
}),
|
||||||
env: redactEnvForLogs(env),
|
env: redactEnvForLogs(env),
|
||||||
@@ -139,18 +215,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
|
stdin: prompt,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
graceSec,
|
graceSec,
|
||||||
onLog,
|
onLog: async (stream, chunk) => {
|
||||||
|
if (stream !== "stderr") {
|
||||||
|
await onLog(stream, chunk);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleaned = stripCodexRolloutNoise(chunk);
|
||||||
|
if (!cleaned.trim()) return;
|
||||||
|
await onLog(stream, cleaned);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
||||||
return {
|
return {
|
||||||
proc,
|
proc: {
|
||||||
|
...proc,
|
||||||
|
stderr: cleanedStderr,
|
||||||
|
},
|
||||||
|
rawStderr: proc.stderr,
|
||||||
parsed: parseCodexJsonl(proc.stdout),
|
parsed: parseCodexJsonl(proc.stdout),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const toResult = (
|
const toResult = (
|
||||||
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; parsed: ReturnType<typeof parseCodexJsonl> },
|
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType<typeof parseCodexJsonl> },
|
||||||
clearSessionOnMissingSession = false,
|
clearSessionOnMissingSession = false,
|
||||||
): AdapterExecutionResult => {
|
): AdapterExecutionResult => {
|
||||||
if (attempt.proc.timedOut) {
|
if (attempt.proc.timedOut) {
|
||||||
@@ -167,6 +257,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const resolvedSessionParams = resolvedSessionId
|
const resolvedSessionParams = resolvedSessionId
|
||||||
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
|
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
|
const fallbackErrorMessage =
|
||||||
|
parsedError ||
|
||||||
|
stderrLine ||
|
||||||
|
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: attempt.proc.exitCode,
|
exitCode: attempt.proc.exitCode,
|
||||||
@@ -175,7 +271,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
errorMessage:
|
errorMessage:
|
||||||
(attempt.proc.exitCode ?? 0) === 0
|
(attempt.proc.exitCode ?? 0) === 0
|
||||||
? null
|
? null
|
||||||
: `Codex exited with code ${attempt.proc.exitCode ?? -1}`,
|
: fallbackErrorMessage,
|
||||||
usage: attempt.parsed.usage,
|
usage: attempt.parsed.usage,
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
sessionParams: resolvedSessionParams,
|
sessionParams: resolvedSessionParams,
|
||||||
@@ -197,7 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
sessionId &&
|
sessionId &&
|
||||||
!initial.proc.timedOut &&
|
!initial.proc.timedOut &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
isCodexUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
||||||
) {
|
) {
|
||||||
await onLog(
|
await onLog(
|
||||||
"stderr",
|
"stderr",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { asString, asNumber, parseObject, parseJson } from "@paperclip/adapter-u
|
|||||||
export function parseCodexJsonl(stdout: string) {
|
export function parseCodexJsonl(stdout: string) {
|
||||||
let sessionId: string | null = null;
|
let sessionId: string | null = null;
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
|
let errorMessage: string | null = null;
|
||||||
const usage = {
|
const usage = {
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
cachedInputTokens: 0,
|
cachedInputTokens: 0,
|
||||||
@@ -22,6 +23,12 @@ export function parseCodexJsonl(stdout: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "error") {
|
||||||
|
const msg = asString(event.message, "").trim();
|
||||||
|
if (msg) errorMessage = msg;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "item.completed") {
|
if (type === "item.completed") {
|
||||||
const item = parseObject(event.item);
|
const item = parseObject(event.item);
|
||||||
if (asString(item.type, "") === "agent_message") {
|
if (asString(item.type, "") === "agent_message") {
|
||||||
@@ -36,6 +43,13 @@ export function parseCodexJsonl(stdout: string) {
|
|||||||
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
||||||
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
||||||
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "turn.failed") {
|
||||||
|
const err = parseObject(event.error);
|
||||||
|
const msg = asString(err.message, "").trim();
|
||||||
|
if (msg) errorMessage = msg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +57,7 @@ export function parseCodexJsonl(stdout: string) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
summary: messages.join("\n\n").trim(),
|
summary: messages.join("\n\n").trim(),
|
||||||
usage,
|
usage,
|
||||||
|
errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +67,7 @@ export function isCodexUnknownSessionError(stdout: string, stderr: string): bool
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found/i.test(
|
return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found|missing rollout path for thread|state db missing rollout path/i.test(
|
||||||
haystack,
|
haystack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
server/src/__tests__/codex-local-adapter.test.ts
Normal file
32
server/src/__tests__/codex-local-adapter.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isCodexUnknownSessionError, parseCodexJsonl } from "@paperclip/adapter-codex-local/server";
|
||||||
|
|
||||||
|
describe("codex_local parser", () => {
|
||||||
|
it("extracts session, summary, usage, and terminal error message", () => {
|
||||||
|
const stdout = [
|
||||||
|
JSON.stringify({ type: "thread.started", thread_id: "thread-123" }),
|
||||||
|
JSON.stringify({ type: "item.completed", item: { type: "agent_message", text: "hello" } }),
|
||||||
|
JSON.stringify({ type: "turn.completed", usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 } }),
|
||||||
|
JSON.stringify({ type: "turn.failed", error: { message: "model access denied" } }),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const parsed = parseCodexJsonl(stdout);
|
||||||
|
expect(parsed.sessionId).toBe("thread-123");
|
||||||
|
expect(parsed.summary).toBe("hello");
|
||||||
|
expect(parsed.usage).toEqual({
|
||||||
|
inputTokens: 10,
|
||||||
|
cachedInputTokens: 2,
|
||||||
|
outputTokens: 4,
|
||||||
|
});
|
||||||
|
expect(parsed.errorMessage).toBe("model access denied");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("codex_local stale session detection", () => {
|
||||||
|
it("treats missing rollout path as an unknown session error", () => {
|
||||||
|
const stderr =
|
||||||
|
"2026-02-19T19:58:53.281939Z ERROR codex_core::rollout::list: state db missing rollout path for thread 019c775d-967c-7ef1-acc7-e396dc2c87cc";
|
||||||
|
|
||||||
|
expect(isCodexUnknownSessionError("", stderr)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user