From 6e335b3fd0302033e9e8fde9c823a72163869877 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Thu, 19 Feb 2026 14:39:37 -0600 Subject: [PATCH] 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 --- .../claude-local/src/server/execute.ts | 3 + packages/adapters/codex-local/src/index.ts | 5 + .../codex-local/src/server/execute.ts | 112 ++++++++++++++++-- .../adapters/codex-local/src/server/parse.ts | 17 ++- .../src/__tests__/codex-local-adapter.test.ts | 32 +++++ 5 files changed, 160 insertions(+), 9 deletions(-) create mode 100644 server/src/__tests__/codex-local-adapter.test.ts diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index e3306694..12310e20 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -134,6 +134,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 { const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; @@ -31,6 +103,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; @@ -102,6 +175,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - if (resumeSessionId) args.push("resume", resumeSessionId, prompt); - else args.push(prompt); + if (resumeSessionId) args.push("resume", resumeSessionId, "-"); + else args.push("-"); return args; }; @@ -127,7 +203,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (idx === args.length - 1) return ``; + if (idx === args.length - 1 && value !== "-") return ``; return value; }), env: redactEnvForLogs(env), @@ -139,18 +215,32 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + 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 { - proc, + proc: { + ...proc, + stderr: cleanedStderr, + }, + rawStderr: proc.stderr, parsed: parseCodexJsonl(proc.stdout), }; }; const toResult = ( - attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; parsed: ReturnType }, + attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType }, clearSessionOnMissingSession = false, ): AdapterExecutionResult => { if (attempt.proc.timedOut) { @@ -167,6 +257,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : 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 { exitCode: attempt.proc.exitCode, @@ -175,7 +271,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise line.trim()) .filter(Boolean) .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, ); } diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts new file mode 100644 index 00000000..24644d9d --- /dev/null +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -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); + }); +});