Add adapter session codecs with cwd-aware resume and unknown-session retry

Introduce AdapterSessionCodec interface for structured session serialization,
deserialization, and display ID extraction. Implement codecs for claude_local
and codex_local adapters with cwd validation — sessions saved for a different
working directory are not resumed. Both adapters now return sessionParams and
sessionDisplayId alongside legacy sessionId.

Add isCodexUnknownSessionError detection and automatic retry with fresh session
for codex_local (matching existing claude_local behavior). Inject approval
context env vars (PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS,
PAPERCLIP_LINKED_ISSUE_IDS) into adapter environments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 14:01:58 -06:00
parent 4e3da49116
commit d56618e9fe
11 changed files with 300 additions and 65 deletions

View File

@@ -5,6 +5,7 @@ export type {
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,
AdapterSessionCodec,
ServerAdapterModule,
TranscriptEntry,
StdoutLineParser,

View File

@@ -11,7 +11,13 @@ export interface AdapterAgent {
}
export interface AdapterRuntime {
/**
* Legacy single session id view. Prefer `sessionParams` + `sessionDisplayId`.
*/
sessionId: string | null;
sessionParams: Record<string, unknown> | null;
sessionDisplayId: string | null;
taskKey: string | null;
}
// ---------------------------------------------------------------------------
@@ -30,7 +36,12 @@ export interface AdapterExecutionResult {
timedOut: boolean;
errorMessage?: string | null;
usage?: UsageSummary;
/**
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.
*/
sessionId?: string | null;
sessionParams?: Record<string, unknown> | null;
sessionDisplayId?: string | null;
provider?: string | null;
model?: string | null;
costUsd?: number | null;
@@ -39,6 +50,12 @@ export interface AdapterExecutionResult {
clearSession?: boolean;
}
export interface AdapterSessionCodec {
deserialize(raw: unknown): Record<string, unknown> | null;
serialize(params: Record<string, unknown> | null): Record<string, unknown> | null;
getDisplayId?: (params: Record<string, unknown> | null) => string | null;
}
export interface AdapterInvocationMeta {
adapterType: string;
command: string;
@@ -63,6 +80,7 @@ export interface AdapterExecutionContext {
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
sessionCodec?: AdapterSessionCodec;
supportsLocalAgentJwt?: boolean;
models?: { id: string; label: string }[];
agentConfigurationDoc?: string;

View File

@@ -119,7 +119,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
})();
const skillsDir = await buildSkillsDir();
const sessionId = runtime.sessionId;
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const template = sessionId ? promptTemplate : bootstrapTemplate;
const prompt = renderTemplate(template, {
company: { id: agent.companyId },
@@ -230,6 +242,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const resolvedSessionId =
parsedStream.sessionId ??
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
const resolvedSessionParams = resolvedSessionId
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
: null;
return {
exitCode: proc.exitCode,
@@ -241,6 +256,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "anthropic",
model: parsedStream.model || asString(parsed.model, model),
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
@@ -267,7 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
}
return toAdapterResult(initial, { fallbackSessionId: runtime.sessionId });
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
} finally {
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
}

View File

@@ -1,2 +1,35 @@
export { execute } from "./execute.js";
export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
return cwd ? { sessionId, cwd } : { sessionId };
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
return cwd ? { sessionId, cwd } : { sessionId };
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
import {
asString,
@@ -13,7 +14,7 @@ import {
renderTemplate,
runChildProcess,
} from "@paperclip/adapter-utils/server-utils";
import { parseCodexJsonl } from "./parse.js";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
@@ -86,7 +87,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
return asStringArray(config.args);
})();
const sessionId = runtime.sessionId;
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
const sessionId = canResumeSession ? runtimeSessionId : null;
if (runtimeSessionId && !canResumeSession) {
await onLog(
"stderr",
`[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
);
}
const template = sessionId ? promptTemplate : bootstrapTemplate;
const prompt = renderTemplate(template, {
company: { id: agent.companyId },
@@ -95,63 +108,104 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
context,
});
const args = ["exec", "--json"];
if (search) args.unshift("--search");
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
if (model) args.push("--model", model);
if (extraArgs.length > 0) args.push(...extraArgs);
if (sessionId) args.push("resume", sessionId, prompt);
else args.push(prompt);
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command,
cwd,
commandArgs: args.map((value, idx) => {
if (!sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
if (sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
return value;
}),
env: redactEnvForLogs(env),
prompt,
context,
});
}
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog,
});
if (proc.timedOut) {
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
};
}
const parsed = parseCodexJsonl(proc.stdout);
return {
exitCode: proc.exitCode,
signal: proc.signal,
timedOut: false,
errorMessage: (proc.exitCode ?? 0) === 0 ? null : `Codex exited with code ${proc.exitCode ?? -1}`,
usage: parsed.usage,
sessionId: parsed.sessionId ?? runtime.sessionId,
provider: "openai",
model,
costUsd: null,
resultJson: {
stdout: proc.stdout,
stderr: proc.stderr,
},
summary: parsed.summary,
const buildArgs = (resumeSessionId: string | null) => {
const args = ["exec", "--json"];
if (search) args.unshift("--search");
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
if (model) args.push("--model", model);
if (extraArgs.length > 0) args.push(...extraArgs);
if (resumeSessionId) args.push("resume", resumeSessionId, prompt);
else args.push(prompt);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "codex_local",
command,
cwd,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
return value;
}),
env: redactEnvForLogs(env),
prompt,
context,
});
}
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
timeoutSec,
graceSec,
onLog,
});
return {
proc,
parsed: parseCodexJsonl(proc.stdout),
};
};
const toResult = (
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; parsed: ReturnType<typeof parseCodexJsonl> },
clearSessionOnMissingSession = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
const resolvedSessionParams = resolvedSessionId
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
: null;
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage:
(attempt.proc.exitCode ?? 0) === 0
? null
: `Codex exited with code ${attempt.proc.exitCode ?? -1}`,
usage: attempt.parsed.usage,
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "openai",
model,
costUsd: null,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isCodexUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stderr",
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);
}
return toResult(initial);
}

View File

@@ -1,2 +1,35 @@
export { execute } from "./execute.js";
export { parseCodexJsonl } from "./parse.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
import type { AdapterSessionCodec } from "@paperclip/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
return cwd ? { sessionId, cwd } : { sessionId };
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
return cwd ? { sessionId, cwd } : { sessionId };
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};

View File

@@ -45,3 +45,14 @@ export function parseCodexJsonl(stdout: string) {
usage,
};
}
export function isCodexUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found/i.test(
haystack,
);
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { sessionCodec as claudeSessionCodec } from "@paperclip/adapter-claude-local/server";
import { sessionCodec as codexSessionCodec, isCodexUnknownSessionError } from "@paperclip/adapter-codex-local/server";
describe("adapter session codecs", () => {
it("normalizes claude session params with cwd", () => {
const parsed = claudeSessionCodec.deserialize({
session_id: "claude-session-1",
folder: "/tmp/workspace",
});
expect(parsed).toEqual({
sessionId: "claude-session-1",
cwd: "/tmp/workspace",
});
const serialized = claudeSessionCodec.serialize(parsed);
expect(serialized).toEqual({
sessionId: "claude-session-1",
cwd: "/tmp/workspace",
});
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
});
it("normalizes codex session params with cwd", () => {
const parsed = codexSessionCodec.deserialize({
sessionId: "codex-session-1",
cwd: "/tmp/codex",
});
expect(parsed).toEqual({
sessionId: "codex-session-1",
cwd: "/tmp/codex",
});
const serialized = codexSessionCodec.serialize(parsed);
expect(serialized).toEqual({
sessionId: "codex-session-1",
cwd: "/tmp/codex",
});
expect(codexSessionCodec.getDisplayId?.(serialized ?? null)).toBe("codex-session-1");
});
});
describe("codex resume recovery detection", () => {
it("detects unknown session errors from codex output", () => {
expect(
isCodexUnknownSessionError(
'{"type":"error","message":"Unknown session id abc"}',
"",
),
).toBe(true);
expect(
isCodexUnknownSessionError(
"",
"thread 123 not found",
),
).toBe(true);
expect(
isCodexUnknownSessionError(
'{"type":"result","ok":true}',
"",
),
).toBe(false);
});
});

View File

@@ -4,6 +4,7 @@ export type {
AdapterExecutionContext,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterSessionCodec,
UsageSummary,
AdapterAgent,
AdapterRuntime,

View File

@@ -1,7 +1,7 @@
import type { ServerAdapterModule } from "./types.js";
import { execute as claudeExecute } from "@paperclip/adapter-claude-local/server";
import { execute as claudeExecute, sessionCodec as claudeSessionCodec } from "@paperclip/adapter-claude-local/server";
import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclip/adapter-claude-local";
import { execute as codexExecute } from "@paperclip/adapter-codex-local/server";
import { execute as codexExecute, sessionCodec as codexSessionCodec } from "@paperclip/adapter-codex-local/server";
import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local";
import { processAdapter } from "./process/index.js";
import { httpAdapter } from "./http/index.js";
@@ -9,6 +9,7 @@ import { httpAdapter } from "./http/index.js";
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: claudeExecute,
sessionCodec: claudeSessionCodec,
models: claudeModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: claudeAgentConfigurationDoc,
@@ -17,6 +18,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
const codexLocalAdapter: ServerAdapterModule = {
type: "codex_local",
execute: codexExecute,
sessionCodec: codexSessionCodec,
models: codexModels,
supportsLocalAgentJwt: true,
agentConfigurationDoc: codexAgentConfigurationDoc,

View File

@@ -8,5 +8,6 @@ export type {
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterExecutionContext,
AdapterSessionCodec,
ServerAdapterModule,
} from "@paperclip/adapter-utils";