feat(codex): bind CLI sessions from nodes

Adds node-backed Codex CLI session listing and resume binding for paired nodes, including Windows shim-safe Codex resume spawning, docs, changelog, and focused Codex coverage.

Verification:
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/plugins/codex-harness.md extensions/codex/index.ts extensions/codex/src/command-formatters.ts extensions/codex/src/command-handlers.ts extensions/codex/src/commands.test.ts extensions/codex/src/conversation-binding-data.ts extensions/codex/src/conversation-binding.test.ts extensions/codex/src/conversation-binding.ts extensions/codex/src/node-cli-sessions.ts extensions/codex/src/node-cli-sessions.test.ts
- pnpm run lint:tmp:no-random-messaging
- pnpm run lint:extensions:bundled
- OPENCLAW_VITEST_MAX_WORKERS=4 pnpm test extensions/codex/src/node-cli-sessions.test.ts extensions/codex/src/conversation-binding.test.ts extensions/codex/src/commands.test.ts
- pnpm tsgo:extensions
- git diff --check
- AWS Crabbox focused proof run_a901a61e006f
This commit is contained in:
Mariano
2026-05-14 11:24:30 +02:00
committed by GitHub
parent 2268ce3a14
commit a5c1956ca1
11 changed files with 1342 additions and 20 deletions

View File

@@ -288,6 +288,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
- Codex: add node-backed Codex CLI session listing and binding so an OpenClaw conversation can continue an existing Codex CLI session running on a paired node.
### Fixes

View File

@@ -177,13 +177,14 @@ Keep provider refs and runtime policy separate:
Common command routing:
| User intent | Use |
| ------------------------------- | --------------------------------------- |
| Attach the current chat | `/codex bind [--cwd <path>]` |
| Resume an existing Codex thread | `/codex resume <thread-id>` |
| List or filter Codex threads | `/codex threads [filter]` |
| Send Codex feedback only | `/codex diagnostics [note]` |
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
| User intent | Use |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Attach the current chat | `/codex bind [--cwd <path>]` |
| Resume an existing Codex thread | `/codex resume <thread-id>` |
| List or filter Codex threads | `/codex threads [filter]` |
| Attach an existing Codex CLI session on a paired node | `/codex sessions --host <node> [filter]`, then `/codex resume <session-id> --host <node> --bind here` |
| Send Codex feedback only | `/codex diagnostics [note]` |
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
| Use case | Configure | Verify | Notes |
| ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------- | ---------------------------------- |

View File

@@ -10,6 +10,13 @@ import {
handleCodexConversationInboundClaim,
} from "./src/conversation-binding.js";
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
import {
createCodexCliSessionNodeHostCommands,
createCodexCliSessionNodeInvokePolicies,
listCodexCliSessionsOnNode,
resumeCodexCliSessionOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./src/node-cli-sessions.js";
export default definePluginEntry({
id: "codex",
@@ -30,10 +37,28 @@ export default definePluginEntry({
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
);
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
for (const command of createCodexCliSessionNodeHostCommands()) {
api.registerNodeHostCommand(command);
}
for (const policy of createCodexCliSessionNodeInvokePolicies()) {
api.registerNodeInvokePolicy(policy);
}
api.registerCommand(
createCodexCommand({
pluginConfig: api.pluginConfig,
deps: {
listCodexCliSessionsOnNode: (params) =>
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
resolveCodexCliSessionForBindingOnNode: (params) =>
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
},
}),
);
api.on("inbound_claim", (event, ctx) =>
handleCodexConversationInboundClaim(event, ctx, {
pluginConfig: resolveCurrentPluginConfig(),
resumeCodexCliSessionOnNode: (params) =>
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
}),
);
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);

View File

@@ -301,7 +301,9 @@ export function buildHelp(): string {
"- /codex status",
"- /codex models",
"- /codex threads [filter]",
"- /codex sessions --host <node> [filter]",
"- /codex resume <thread-id>",
"- /codex resume <session-id> --host <node> --bind here",
"- /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
"- /codex binding",
"- /codex stop",

View File

@@ -36,6 +36,7 @@ import {
type SafeValue,
} from "./command-rpc.js";
import {
createCodexCliNodeConversationBindingData,
readCodexConversationBindingData,
resolveCodexDefaultWorkspaceDir,
startCodexConversationThread,
@@ -51,6 +52,11 @@ import {
steerCodexConversationTurn,
stopCodexConversationTurn,
} from "./conversation-control.js";
import {
formatCodexCliSessions,
listCodexCliSessionsOnNode,
resolveCodexCliSessionForBindingOnNode,
} from "./node-cli-sessions.js";
export type CodexCommandDeps = {
codexControlRequest: CodexControlRequestFn;
@@ -71,6 +77,8 @@ export type CodexCommandDeps = {
setCodexConversationPermissions: typeof setCodexConversationPermissions;
steerCodexConversationTurn: typeof steerCodexConversationTurn;
stopCodexConversationTurn: typeof stopCodexConversationTurn;
listCodexCliSessionsOnNode: ListCodexCliSessionsOnNodeFn;
resolveCodexCliSessionForBindingOnNode: ResolveCodexCliSessionForBindingOnNodeFn;
};
type CodexControlRequestFn = (
@@ -87,6 +95,14 @@ type SafeCodexControlRequestFn = (
options?: CodexControlRequestOptions,
) => Promise<SafeValue<JsonValue | undefined>>;
type ListCodexCliSessionsOnNodeFn = (
params: Omit<Parameters<typeof listCodexCliSessionsOnNode>[0], "runtime">,
) => ReturnType<typeof listCodexCliSessionsOnNode>;
type ResolveCodexCliSessionForBindingOnNodeFn = (
params: Omit<Parameters<typeof resolveCodexCliSessionForBindingOnNode>[0], "runtime">,
) => ReturnType<typeof resolveCodexCliSessionForBindingOnNode>;
const defaultCodexCommandDeps: CodexCommandDeps = {
codexControlRequest,
listCodexAppServerModels: listAllCodexAppServerModels,
@@ -106,6 +122,12 @@ const defaultCodexCommandDeps: CodexCommandDeps = {
setCodexConversationPermissions,
steerCodexConversationTurn,
stopCodexConversationTurn,
listCodexCliSessionsOnNode: async () => {
throw new Error("Codex CLI node sessions require Gateway node runtime.");
},
resolveCodexCliSessionForBindingOnNode: async () => {
throw new Error("Codex CLI node sessions require Gateway node runtime.");
},
};
type ParsedBindArgs = {
@@ -123,6 +145,20 @@ type ParsedComputerUseArgs = {
help?: boolean;
};
type ParsedCodexCliSessionsArgs = {
host?: string;
filter: string;
limit?: number;
help?: boolean;
};
type ParsedResumeArgs = {
threadId?: string;
host?: string;
bindHere?: boolean;
help?: boolean;
};
type ParsedDiagnosticsArgs =
| { action: "request"; note: string }
| { action: "confirm"; token: string }
@@ -214,6 +250,9 @@ export async function handleCodexSubcommand(
if (normalized === "threads") {
return { text: await buildThreads(deps, options.pluginConfig, rest.join(" ")) };
}
if (normalized === "sessions") {
return { text: await buildCodexCliSessions(deps, rest) };
}
if (normalized === "resume") {
return { text: await resumeThread(deps, ctx, options.pluginConfig, rest) };
}
@@ -437,7 +476,7 @@ async function detachConversation(
const current = await ctx.getCurrentConversationBinding();
const data = readCodexConversationBindingData(current);
const detached = await ctx.detachConversationBinding();
if (data) {
if (data?.kind === "codex-app-server-session") {
await deps.clearCodexAppServerBinding(data.sessionFile);
} else if (ctx.sessionFile) {
await deps.clearCodexAppServerBinding(ctx.sessionFile);
@@ -456,6 +495,16 @@ async function describeConversationBinding(
if (!current || !data) {
return "No Codex conversation binding is attached.";
}
if (data.kind === "codex-cli-node-session") {
return [
"Codex conversation binding:",
"- Mode: Codex CLI node session",
`- Node: ${formatCodexDisplayText(data.nodeId)}`,
`- Session: ${formatCodexDisplayText(data.sessionId)}`,
`- Workspace: ${formatCodexDisplayText(data.cwd ?? "unknown")}`,
"- Active run: not tracked",
].join("\n");
}
const threadBinding = await deps.readCodexAppServerBinding(data.sessionFile);
const active = deps.readCodexConversationActiveTurn(data.sessionFile);
return [
@@ -482,14 +531,36 @@ async function buildThreads(
return formatThreads(response);
}
async function buildCodexCliSessions(deps: CodexCommandDeps, args: string[]): Promise<string> {
const parsed = parseCodexCliSessionsArgs(args);
if (parsed.help || !parsed.host) {
return "Usage: /codex sessions --host <node> [filter] [--limit <n>]";
}
return formatCodexCliSessions(
await deps.listCodexCliSessionsOnNode({
requestedNode: parsed.host,
filter: parsed.filter,
limit: parsed.limit,
}),
);
}
async function resumeThread(
deps: CodexCommandDeps,
ctx: PluginCommandContext,
pluginConfig: unknown,
args: string[],
): Promise<string> {
const [threadId] = args;
const normalizedThreadId = threadId?.trim();
const parsed = parseResumeArgs(args);
const normalizedThreadId = parsed.threadId?.trim();
if (parsed.help) {
return args.includes("--help") || args.includes("-h") || parsed.host
? "Usage: /codex resume <thread-id>\nUsage: /codex resume <session-id> --host <node> --bind here"
: "Usage: /codex resume <thread-id>";
}
if (parsed.host) {
return await bindCodexCliNodeSession(deps, ctx, parsed);
}
if (!normalizedThreadId || args.length !== 1) {
return "Usage: /codex resume <thread-id>";
}
@@ -517,6 +588,47 @@ async function resumeThread(
)}.`;
}
async function bindCodexCliNodeSession(
deps: CodexCommandDeps,
ctx: PluginCommandContext,
parsed: ParsedResumeArgs,
): Promise<string> {
if (!parsed.threadId || !parsed.host || parsed.bindHere !== true) {
return "Usage: /codex resume <session-id> --host <node> --bind here";
}
const resolved = await deps.resolveCodexCliSessionForBindingOnNode({
requestedNode: parsed.host,
sessionId: parsed.threadId,
});
if (!resolved.session) {
return `No Codex CLI session ${formatCodexDisplayText(parsed.threadId)} was found on ${formatCodexDisplayText(parsed.host)}.`;
}
const nodeId = resolved.node.nodeId;
if (!nodeId) {
return "Cannot bind Codex CLI session because the selected node did not include a node id.";
}
const data = createCodexCliNodeConversationBindingData({
nodeId,
sessionId: parsed.threadId,
cwd: resolved.session?.cwd,
});
const summary = `Codex CLI session ${formatCodexDisplayText(parsed.threadId)} on ${formatCodexDisplayText(nodeId)}`;
const request = await ctx.requestConversationBinding({
summary,
detachHint: "/codex detach",
data,
});
if (request.status === "bound") {
return `Bound this conversation to Codex CLI session ${formatCodexDisplayText(
parsed.threadId,
)} on ${formatCodexDisplayText(nodeId)}.`;
}
if (request.status === "pending") {
return request.reply.text ?? "Codex CLI session binding is pending approval.";
}
return formatCodexDisplayText(request.message);
}
async function stopConversationTurn(
deps: CodexCommandDeps,
ctx: PluginCommandContext,
@@ -628,7 +740,8 @@ async function setConversationPermissions(
async function resolveControlSessionFile(ctx: PluginCommandContext): Promise<string | undefined> {
const binding = await ctx.getCurrentConversationBinding();
return readCodexConversationBindingData(binding)?.sessionFile ?? ctx.sessionFile;
const data = readCodexConversationBindingData(binding);
return data?.kind === "codex-app-server-session" ? data.sessionFile : ctx.sessionFile;
}
async function handleCodexDiagnosticsFeedback(
@@ -1613,6 +1726,86 @@ function parseBindArgs(args: string[]): ParsedBindArgs {
return parsed;
}
function parseCodexCliSessionsArgs(args: string[]): ParsedCodexCliSessionsArgs {
const parsed: ParsedCodexCliSessionsArgs = { filter: "" };
const filter: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
parsed.help = true;
continue;
}
if (arg === "--host" || arg === "--node") {
const value = readRequiredOptionValue(args, index);
if (!value || parsed.host !== undefined) {
parsed.help = true;
continue;
}
parsed.host = value;
index += 1;
continue;
}
if (arg === "--limit") {
const value = readRequiredOptionValue(args, index);
const parsedLimit = value ? Number.parseInt(value, 10) : Number.NaN;
if (!Number.isFinite(parsedLimit) || parsedLimit <= 0) {
parsed.help = true;
continue;
}
parsed.limit = parsedLimit;
index += 1;
continue;
}
if (arg.startsWith("-")) {
parsed.help = true;
continue;
}
filter.push(arg);
}
parsed.host = normalizeOptionalString(parsed.host);
parsed.filter = filter.join(" ").trim();
return parsed;
}
function parseResumeArgs(args: string[]): ParsedResumeArgs {
const parsed: ParsedResumeArgs = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
parsed.help = true;
continue;
}
if (arg === "--host" || arg === "--node") {
const value = readRequiredOptionValue(args, index);
if (!value || parsed.host !== undefined) {
parsed.help = true;
continue;
}
parsed.host = value;
index += 1;
continue;
}
if (arg === "--bind") {
const value = readRequiredOptionValue(args, index);
if (value !== "here" || parsed.bindHere !== undefined) {
parsed.help = true;
continue;
}
parsed.bindHere = true;
index += 1;
continue;
}
if (!arg.startsWith("-") && !parsed.threadId) {
parsed.threadId = arg;
continue;
}
parsed.help = true;
}
parsed.threadId = normalizeOptionalString(parsed.threadId);
parsed.host = normalizeOptionalString(parsed.host);
return parsed;
}
function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs {
const parsed: ParsedComputerUseArgs = {
action: "status",

View File

@@ -265,6 +265,116 @@ describe("codex command", () => {
expect(writeCodexAppServerBinding).not.toHaveBeenCalled();
});
it("lists Codex CLI sessions from a requested node", async () => {
const listCodexCliSessionsOnNode = vi.fn(async () => ({
node: { nodeId: "mb-m5", displayName: "mb-m5" },
result: {
codexHome: "/Users/mariano/.codex",
sessions: [
{
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
cwd: "/repo",
updatedAt: "2026-05-13T06:30:00.000Z",
lastMessage: "fix the bridge",
messageCount: 2,
},
],
},
}));
const result = await handleCodexCommand(createContext("sessions --host mb-m5 bridge"), {
deps: createDeps({ listCodexCliSessionsOnNode }),
});
expect(result.text).toContain("Codex CLI sessions on mb-m5 / mb-m5:");
expect(result.text).toContain("019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd");
expect(result.text).toContain(
"Bind: /codex resume 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd --host mb-m5 --bind here",
);
expect(listCodexCliSessionsOnNode).toHaveBeenCalledWith({
requestedNode: "mb-m5",
filter: "bridge",
limit: undefined,
});
});
it("binds the current conversation to a Codex CLI node session", async () => {
const requestConversationBinding = vi.fn(async () => ({
status: "bound" as const,
binding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: "/plugin",
channel: "test",
accountId: "default",
conversationId: "conversation",
boundAt: 1,
},
}));
const resolveCodexCliSessionForBindingOnNode = vi.fn(async () => ({
node: { nodeId: "node-123", displayName: "mb-m5" },
session: {
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
cwd: "/repo",
messageCount: 2,
},
}));
await expect(
handleCodexCommand(
createContext(
"resume 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd --host mb-m5 --bind here",
undefined,
{ requestConversationBinding },
),
{
deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
},
),
).resolves.toEqual({
text: "Bound this conversation to Codex CLI session 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd on node-123.",
});
expect(resolveCodexCliSessionForBindingOnNode).toHaveBeenCalledWith({
requestedNode: "mb-m5",
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
});
expect(requestConversationBinding).toHaveBeenCalledWith({
summary: "Codex CLI session 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd on node-123",
detachHint: "/codex detach",
data: {
kind: "codex-cli-node-session",
version: 1,
nodeId: "node-123",
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
cwd: "/repo",
},
});
});
it("refuses to bind a Codex CLI node session that the node did not list", async () => {
const requestConversationBinding = vi.fn();
const resolveCodexCliSessionForBindingOnNode = vi.fn(async () => ({
node: { nodeId: "node-123", displayName: "mb-m5" },
session: undefined,
}));
await expect(
handleCodexCommand(
createContext(
"resume 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd --host mb-m5 --bind here",
undefined,
{ requestConversationBinding },
),
{
deps: createDeps({ resolveCodexCliSessionForBindingOnNode }),
},
),
).resolves.toEqual({
text: "No Codex CLI session 019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd was found on mb-m5.",
});
expect(requestConversationBinding).not.toHaveBeenCalled();
});
it("escapes resumed Codex thread ids before chat display", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const unsafe = "thread-123 <@U123> [trusted](https://evil)";

View File

@@ -3,17 +3,29 @@ import type { PluginConversationBinding } from "openclaw/plugin-sdk/plugin-entry
const BINDING_DATA_VERSION = 1;
export type CodexConversationBindingData = {
export type CodexAppServerConversationBindingData = {
kind: "codex-app-server-session";
version: 1;
sessionFile: string;
workspaceDir: string;
};
export type CodexCliNodeConversationBindingData = {
kind: "codex-cli-node-session";
version: 1;
nodeId: string;
sessionId: string;
cwd?: string;
};
export type CodexConversationBindingData =
| CodexAppServerConversationBindingData
| CodexCliNodeConversationBindingData;
export function createCodexConversationBindingData(params: {
sessionFile: string;
workspaceDir: string;
}): CodexConversationBindingData {
}): CodexAppServerConversationBindingData {
return {
kind: "codex-app-server-session",
version: BINDING_DATA_VERSION,
@@ -22,6 +34,21 @@ export function createCodexConversationBindingData(params: {
};
}
export function createCodexCliNodeConversationBindingData(params: {
nodeId: string;
sessionId: string;
cwd?: string;
}): CodexCliNodeConversationBindingData {
const cwd = params.cwd?.trim();
return {
kind: "codex-cli-node-session",
version: BINDING_DATA_VERSION,
nodeId: params.nodeId,
sessionId: params.sessionId,
...(cwd ? { cwd } : {}),
};
}
export function readCodexConversationBindingData(
binding: PluginConversationBinding | null | undefined,
): CodexConversationBindingData | undefined {
@@ -35,8 +62,28 @@ export function readCodexConversationBindingData(
export function readCodexConversationBindingDataRecord(
data: Record<string, unknown>,
): CodexConversationBindingData | undefined {
if (data.kind === "codex-cli-node-session") {
if (
data.version !== BINDING_DATA_VERSION ||
typeof data.nodeId !== "string" ||
!data.nodeId.trim() ||
typeof data.sessionId !== "string" ||
!data.sessionId.trim()
) {
return undefined;
}
return {
kind: "codex-cli-node-session",
version: BINDING_DATA_VERSION,
nodeId: data.nodeId.trim(),
sessionId: data.sessionId.trim(),
cwd: typeof data.cwd === "string" && data.cwd.trim() ? data.cwd.trim() : undefined,
};
}
if (data.kind !== "codex-app-server-session") {
return undefined;
}
if (
data.kind !== "codex-app-server-session" ||
data.version !== BINDING_DATA_VERSION ||
typeof data.sessionFile !== "string" ||
!data.sessionFile.trim()

View File

@@ -234,6 +234,55 @@ describe("codex conversation binding", () => {
expect(result).toEqual({ handled: true });
});
it("routes bound Codex CLI node sessions through node resume", async () => {
const resumeCodexCliSessionOnNode = vi.fn(async () => ({
ok: true as const,
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
text: "done",
}));
const result = await handleCodexConversationInboundClaim(
{
content: "continue the task",
channel: "discord",
isGroup: true,
commandAuthorized: true,
},
{
channelId: "discord",
pluginBinding: {
bindingId: "binding-1",
pluginId: "codex",
pluginRoot: tempDir,
channel: "discord",
accountId: "default",
conversationId: "channel-1",
boundAt: Date.now(),
data: {
kind: "codex-cli-node-session",
version: 1,
nodeId: "mb-m5",
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
cwd: "/repo",
},
},
},
{
resumeCodexCliSessionOnNode,
timeoutMs: 1234,
},
);
expect(result).toEqual({ handled: true, reply: { text: "done" } });
expect(resumeCodexCliSessionOnNode).toHaveBeenCalledWith({
nodeId: "mb-m5",
sessionId: "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd",
prompt: "continue the task",
cwd: "/repo",
timeoutMs: 1234,
});
});
it("recreates a missing bound thread and preserves auth plus turn overrides", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({

View File

@@ -35,15 +35,17 @@ import {
readCodexConversationBindingData,
readCodexConversationBindingDataRecord,
resolveCodexDefaultWorkspaceDir,
type CodexConversationBindingData,
type CodexAppServerConversationBindingData,
} from "./conversation-binding-data.js";
import { trackCodexConversationActiveTurn } from "./conversation-control.js";
import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
import { buildCodexConversationTurnInput } from "./conversation-turn-input.js";
import { resumeCodexCliSessionOnNode } from "./node-cli-sessions.js";
const DEFAULT_BOUND_TURN_TIMEOUT_MS = 20 * 60_000;
export {
createCodexCliNodeConversationBindingData,
readCodexConversationBindingData,
resolveCodexDefaultWorkspaceDir,
} from "./conversation-binding-data.js";
@@ -51,8 +53,13 @@ export {
type CodexConversationRunOptions = {
pluginConfig?: unknown;
timeoutMs?: number;
resumeCodexCliSessionOnNode?: ResumeCodexCliSessionOnNodeFn;
};
type ResumeCodexCliSessionOnNodeFn = (
params: Omit<Parameters<typeof resumeCodexCliSessionOnNode>[0], "runtime">,
) => ReturnType<typeof resumeCodexCliSessionOnNode>;
type CodexConversationStartParams = {
pluginConfig?: unknown;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
@@ -87,7 +94,7 @@ function getGlobalState(): CodexConversationGlobalState {
export async function startCodexConversationThread(
params: CodexConversationStartParams,
): Promise<CodexConversationBindingData> {
): Promise<CodexAppServerConversationBindingData> {
const workspaceDir =
params.workspaceDir?.trim() || resolveCodexDefaultWorkspaceDir(params.pluginConfig);
const existingBinding = await readCodexAppServerBinding(params.sessionFile, {
@@ -147,6 +154,37 @@ export async function handleCodexConversationInboundClaim(
if (!prompt) {
return { handled: true };
}
if (data.kind === "codex-cli-node-session") {
const resume = options.resumeCodexCliSessionOnNode;
if (!resume) {
return {
handled: true,
reply: {
text: "Codex CLI node binding is unavailable because Gateway node runtime is not attached.",
},
};
}
try {
const result = await enqueueBoundTurn(`${data.nodeId}:${data.sessionId}`, async () => {
const resumed = await resume({
nodeId: data.nodeId,
sessionId: data.sessionId,
prompt,
cwd: data.cwd,
timeoutMs: options.timeoutMs,
});
return { reply: { text: resumed.text.trim() || "Codex completed without a text reply." } };
});
return { handled: true, reply: result.reply };
} catch (error) {
return {
handled: true,
reply: {
text: `Codex CLI node turn failed: ${formatCodexDisplayText(formatErrorMessage(error))}`,
},
};
}
}
try {
const result = await enqueueBoundTurn(data.sessionFile, () =>
runBoundTurnWithMissingThreadRecovery({
@@ -175,7 +213,7 @@ export async function handleCodexConversationBindingResolved(
return;
}
const data = readCodexConversationBindingDataRecord(event.request.data ?? {});
if (!data) {
if (!data || data.kind !== "codex-app-server-session") {
return;
}
await clearCodexAppServerBinding(data.sessionFile);
@@ -317,7 +355,7 @@ async function createThread(params: {
}
async function runBoundTurn(params: {
data: CodexConversationBindingData;
data: CodexAppServerConversationBindingData;
prompt: string;
event: PluginHookInboundClaimEvent;
pluginConfig?: unknown;
@@ -425,7 +463,7 @@ async function runBoundTurn(params: {
}
async function runBoundTurnWithMissingThreadRecovery(params: {
data: CodexConversationBindingData;
data: CodexAppServerConversationBindingData;
prompt: string;
event: PluginHookInboundClaimEvent;
pluginConfig?: unknown;

View File

@@ -0,0 +1,151 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
CODEX_CLI_SESSIONS_LIST_COMMAND,
createCodexCliSessionNodeHostCommands,
resolveCodexCliResumeSpawnInvocation,
} from "./node-cli-sessions.js";
let tempDir: string;
let previousCodexHome: string | undefined;
describe("codex cli node sessions", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-cli-sessions-"));
previousCodexHome = process.env.CODEX_HOME;
process.env.CODEX_HOME = tempDir;
});
afterEach(async () => {
if (previousCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = previousCodexHome;
}
await fs.rm(tempDir, { recursive: true, force: true });
});
it("lists recent sessions from Codex history and hydrates cwd from session files", async () => {
const sessionId = "019e2007-1f7e-7eb1-a42b-8c01f4b9b5cd";
await fs.writeFile(
path.join(tempDir, "history.jsonl"),
[
JSON.stringify({ session_id: sessionId, ts: 1778677925, text: "first ask" }),
JSON.stringify({ session_id: sessionId, ts: 1778678322, text: "latest ask" }),
JSON.stringify({ session_id: "older", ts: 1778670000, text: "skip me" }),
].join("\n"),
);
const sessionDir = path.join(tempDir, "sessions", "2026", "05", "13");
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
path.join(sessionDir, `rollout-2026-05-13T08-29-58-${sessionId}.jsonl`),
`${JSON.stringify({
type: "session_meta",
payload: { id: sessionId, cwd: "/repo" },
})}\n`,
);
const command = createCodexCliSessionNodeHostCommands().find(
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
);
const raw = await command?.handle(JSON.stringify({ filter: "latest", limit: 5 }));
const parsed = JSON.parse(raw ?? "{}") as {
sessions?: Array<{
sessionId?: string;
cwd?: string;
lastMessage?: string;
messageCount?: number;
}>;
};
expect(parsed.sessions).toEqual([
{
sessionId,
updatedAt: "2026-05-13T13:18:42.000Z",
lastMessage: "latest ask",
cwd: "/repo",
sessionFile: path.join(sessionDir, `rollout-2026-05-13T08-29-58-${sessionId}.jsonl`),
messageCount: 2,
},
]);
});
it("lists sessions from Codex session files when history is absent", async () => {
const sessionId = "019e23d1-f33d-78e3-959e-0f56f30a5249";
const sessionDir = path.join(tempDir, "sessions", "2026", "05", "14");
const sessionFile = path.join(sessionDir, `rollout-2026-05-14T00-10-22-${sessionId}.jsonl`);
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
sessionFile,
[
JSON.stringify({
timestamp: "2026-05-14T00:10:23.618Z",
type: "session_meta",
payload: { id: sessionId, cwd: "/tmp/codex-work" },
}),
JSON.stringify({
timestamp: "2026-05-14T00:10:23.619Z",
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: "Reply with exactly: CRABBOX" }],
},
}),
].join("\n"),
);
const command = createCodexCliSessionNodeHostCommands().find(
(entry) => entry.command === CODEX_CLI_SESSIONS_LIST_COMMAND,
);
const raw = await command?.handle(JSON.stringify({ filter: "crabbox", limit: 5 }));
const parsed = JSON.parse(raw ?? "{}") as {
sessions?: Array<{
sessionId?: string;
cwd?: string;
lastMessage?: string;
messageCount?: number;
}>;
};
expect(parsed.sessions).toEqual([
{
sessionId,
updatedAt: "2026-05-14T00:10:23.619Z",
lastMessage: "Reply with exactly: CRABBOX",
cwd: "/tmp/codex-work",
sessionFile,
messageCount: 1,
},
]);
});
it("resolves Windows npm .cmd Codex shims through Node for resume", async () => {
const binDir = path.join(tempDir, "bin");
const entryPath = path.join(binDir, "node_modules", "@openai", "codex", "bin", "codex.js");
const shimPath = path.join(binDir, "codex.cmd");
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, "console.log('codex')\n", "utf8");
await fs.writeFile(
shimPath,
'@ECHO off\r\n"%~dp0\\node_modules\\@openai\\codex\\bin\\codex.js" %*\r\n',
"utf8",
);
const resolved = resolveCodexCliResumeSpawnInvocation(["exec", "resume", "session-id"], {
platform: "win32",
env: { PATH: binDir, PATHEXT: ".CMD;.EXE;.BAT" },
execPath: "C:\\node\\node.exe",
});
expect(resolved).toEqual({
command: "C:\\node\\node.exe",
args: [entryPath, "exec", "resume", "session-id"],
shell: undefined,
windowsHide: true,
});
});
});

View File

@@ -0,0 +1,705 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import type {
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "openclaw/plugin-sdk/windows-spawn";
import { formatCodexDisplayText } from "./command-formatters.js";
export const CODEX_CLI_SESSIONS_LIST_COMMAND = "codex.cli.sessions.list";
export const CODEX_CLI_SESSION_RESUME_COMMAND = "codex.cli.session.resume";
const DEFAULT_SESSION_LIMIT = 10;
const MAX_SESSION_LIMIT = 50;
const DEFAULT_RESUME_TIMEOUT_MS = 20 * 60_000;
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
const activeResumeSessions = new Set<string>();
export type CodexCliSessionSummary = {
sessionId: string;
updatedAt?: string;
lastMessage?: string;
cwd?: string;
sessionFile?: string;
messageCount: number;
};
export type CodexCliSessionsListResult = {
sessions: CodexCliSessionSummary[];
codexHome: string;
};
export type CodexCliSessionResumeResult = {
ok: true;
sessionId: string;
text: string;
};
type CodexCliSessionNodeInfo = {
nodeId?: string;
displayName?: string;
remoteIp?: string;
connected?: boolean;
commands?: string[];
};
type CodexCliResumeSpawnRuntime = {
platform: NodeJS.Platform;
env: NodeJS.ProcessEnv;
execPath: string;
};
const DEFAULT_RESUME_SPAWN_RUNTIME: CodexCliResumeSpawnRuntime = {
platform: process.platform,
env: process.env,
execPath: process.execPath,
};
export function createCodexCliSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
return [
{
command: CODEX_CLI_SESSIONS_LIST_COMMAND,
cap: "codex-cli-sessions",
handle: listLocalCodexCliSessions,
},
{
command: CODEX_CLI_SESSION_RESUME_COMMAND,
cap: "codex-cli-sessions",
dangerous: true,
handle: resumeLocalCodexCliSession,
},
];
}
export function createCodexCliSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [CODEX_CLI_SESSIONS_LIST_COMMAND],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (ctx) => ctx.invokeNode(),
},
{
commands: [CODEX_CLI_SESSION_RESUME_COMMAND],
dangerous: true,
handle: (ctx) => ctx.invokeNode(),
},
];
}
export async function listCodexCliSessionsOnNode(params: {
runtime: PluginRuntime;
requestedNode?: string;
filter?: string;
limit?: number;
}): Promise<{ node: CodexCliSessionNodeInfo; result: CodexCliSessionsListResult }> {
const node = await resolveCodexCliNode({
runtime: params.runtime,
requestedNode: params.requestedNode,
command: CODEX_CLI_SESSIONS_LIST_COMMAND,
});
const raw = await params.runtime.nodes.invoke({
nodeId: readNodeId(node),
command: CODEX_CLI_SESSIONS_LIST_COMMAND,
params: {
limit: params.limit,
filter: params.filter,
},
timeoutMs: 15_000,
});
return { node, result: parseCodexCliSessionsListResult(raw) };
}
export async function resolveCodexCliSessionForBindingOnNode(params: {
runtime: PluginRuntime;
requestedNode: string;
sessionId: string;
}): Promise<{ node: CodexCliSessionNodeInfo; session?: CodexCliSessionSummary }> {
const listing = await listCodexCliSessionsOnNode({
runtime: params.runtime,
requestedNode: params.requestedNode,
filter: params.sessionId,
limit: MAX_SESSION_LIMIT,
});
if (!listing.node.commands?.includes(CODEX_CLI_SESSION_RESUME_COMMAND)) {
throw new Error(
`Node ${formatNodeLabel(listing.node)} does not expose ${CODEX_CLI_SESSION_RESUME_COMMAND}.`,
);
}
return {
node: listing.node,
session: listing.result.sessions.find((session) => session.sessionId === params.sessionId),
};
}
export async function resumeCodexCliSessionOnNode(params: {
runtime: PluginRuntime;
nodeId: string;
sessionId: string;
prompt: string;
cwd?: string;
timeoutMs?: number;
}): Promise<CodexCliSessionResumeResult> {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: CODEX_CLI_SESSION_RESUME_COMMAND,
params: {
sessionId: params.sessionId,
prompt: params.prompt,
cwd: params.cwd,
timeoutMs: params.timeoutMs,
},
timeoutMs: (params.timeoutMs ?? DEFAULT_RESUME_TIMEOUT_MS) + 5_000,
});
const payload = unwrapNodeInvokePayload(raw);
if (!isRecord(payload) || payload.ok !== true || typeof payload.text !== "string") {
throw new Error("Codex CLI resume returned an invalid payload.");
}
return {
ok: true,
sessionId: typeof payload.sessionId === "string" ? payload.sessionId : params.sessionId,
text: payload.text,
};
}
export function formatCodexCliSessions(params: {
node: CodexCliSessionNodeInfo;
result: CodexCliSessionsListResult;
}): string {
if (params.result.sessions.length === 0) {
return `No Codex CLI sessions returned from ${formatCodexDisplayText(formatNodeLabel(params.node))}.`;
}
return [
`Codex CLI sessions on ${formatCodexDisplayText(formatNodeLabel(params.node))}:`,
...params.result.sessions.map((session) => {
const details = [session.cwd, session.updatedAt].filter((value): value is string =>
Boolean(value),
);
return `- ${formatCodexDisplayText(session.sessionId)}${
session.lastMessage ? ` - ${formatCodexDisplayText(session.lastMessage)}` : ""
}${details.length > 0 ? ` (${details.map(formatCodexDisplayText).join(", ")})` : ""}\n Bind: /codex resume ${formatCodexDisplayText(
session.sessionId,
)} --host ${formatCodexDisplayText(readNodeId(params.node))} --bind here`;
}),
].join("\n");
}
async function listLocalCodexCliSessions(paramsJSON?: string | null): Promise<string> {
const params = readRecordParam(paramsJSON);
const limit = normalizeLimit(params.limit);
const filter = typeof params.filter === "string" ? params.filter.trim().toLowerCase() : "";
const codexHome = resolveCodexHome();
const summaries = await readHistorySessions(codexHome);
await hydrateSessionFiles(codexHome, summaries);
await hydrateSessionsFromSessionFiles(codexHome, summaries);
const sessions = [...summaries.values()]
.filter((session) => {
if (!filter) {
return true;
}
return [session.sessionId, session.cwd, session.lastMessage].some((value) =>
value?.toLowerCase().includes(filter),
);
})
.toSorted((a, b) => compareOptionalStringsDesc(a.updatedAt, b.updatedAt))
.slice(0, limit);
return JSON.stringify({ sessions, codexHome } satisfies CodexCliSessionsListResult);
}
async function resumeLocalCodexCliSession(paramsJSON?: string | null): Promise<string> {
const params = readRecordParam(paramsJSON);
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
const prompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) {
throw new Error("Missing or invalid Codex CLI session id.");
}
if (!prompt) {
throw new Error("Missing Codex CLI prompt.");
}
if (activeResumeSessions.has(sessionId)) {
throw new Error(`Codex CLI session ${sessionId} already has an active resume turn.`);
}
activeResumeSessions.add(sessionId);
try {
const text = await runCodexExecResume({
sessionId,
prompt,
cwd: typeof params.cwd === "string" && params.cwd.trim() ? params.cwd.trim() : undefined,
timeoutMs: normalizeTimeoutMs(params.timeoutMs),
});
return JSON.stringify({
ok: true,
sessionId,
text: text.trim() || "Codex completed without a text reply.",
} satisfies CodexCliSessionResumeResult);
} finally {
activeResumeSessions.delete(sessionId);
}
}
async function runCodexExecResume(params: {
sessionId: string;
prompt: string;
cwd?: string;
timeoutMs: number;
}): Promise<string> {
const outputPath = path.join(
await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "openclaw-codex-cli-")),
"last-message.txt",
);
try {
const args = [
"exec",
"resume",
"--skip-git-repo-check",
"--output-last-message",
outputPath,
params.sessionId,
"-",
];
const invocation = resolveCodexCliResumeSpawnInvocation(args, {
platform: process.platform,
env: process.env,
execPath: process.execPath,
});
const child = spawn(invocation.command, invocation.args, {
cwd: params.cwd || process.cwd(),
stdio: ["pipe", "pipe", "pipe"],
env: process.env,
shell: invocation.shell,
windowsHide: invocation.windowsHide,
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let timedOut = false;
let forceKillTimeout: NodeJS.Timeout | undefined;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
forceKillTimeout = setTimeout(() => child.kill("SIGKILL"), 2_000);
forceKillTimeout.unref?.();
}, params.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
child.stdin.end(params.prompt);
const exitCode = await new Promise<number | null>((resolve, reject) => {
child.on("error", reject);
child.on("exit", (code) => resolve(code));
}).finally(() => {
clearTimeout(timeout);
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
}
});
if (timedOut) {
throw new Error(`codex exec resume timed out after ${String(params.timeoutMs)}ms`);
}
if (exitCode !== 0) {
const message =
Buffer.concat(stderr).toString("utf8").trim() ||
Buffer.concat(stdout).toString("utf8").trim() ||
`codex exec resume exited with code ${String(exitCode)}`;
throw new Error(message);
}
return await fs.readFile(outputPath, "utf8");
} finally {
await fs.rm(path.dirname(outputPath), { recursive: true, force: true });
}
}
export function resolveCodexCliResumeSpawnInvocation(
args: string[],
runtime: CodexCliResumeSpawnRuntime = DEFAULT_RESUME_SPAWN_RUNTIME,
): { command: string; args: string[]; shell?: boolean; windowsHide?: boolean } {
const program = resolveWindowsSpawnProgram({
command: "codex",
platform: runtime.platform,
env: runtime.env,
execPath: runtime.execPath,
packageName: "@openai/codex",
});
const resolved = materializeWindowsSpawnProgram(program, args);
return {
command: resolved.command,
args: resolved.argv,
shell: resolved.shell,
windowsHide: resolved.windowsHide,
};
}
async function readHistorySessions(
codexHome: string,
): Promise<Map<string, CodexCliSessionSummary>> {
const summaries = new Map<string, CodexCliSessionSummary>();
const historyPath = path.join(codexHome, "history.jsonl");
const content = await readFileIfExists(historyPath);
if (!content) {
return summaries;
}
for (const line of content.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
continue;
}
if (!isRecord(parsed) || typeof parsed.session_id !== "string") {
continue;
}
const sessionId = parsed.session_id.trim();
if (!sessionId) {
continue;
}
const entry = summaries.get(sessionId) ?? {
sessionId,
messageCount: 0,
};
entry.messageCount += 1;
if (typeof parsed.text === "string" && parsed.text.trim()) {
entry.lastMessage = truncateText(parsed.text.trim(), 140);
}
if (typeof parsed.ts === "number" && Number.isFinite(parsed.ts)) {
entry.updatedAt = new Date(parsed.ts * 1000).toISOString();
}
summaries.set(sessionId, entry);
}
return summaries;
}
async function hydrateSessionFiles(
codexHome: string,
summaries: Map<string, CodexCliSessionSummary>,
): Promise<void> {
if (summaries.size === 0) {
return;
}
const sessionsDir = path.join(codexHome, "sessions");
const files = await findSessionFiles(sessionsDir, 4);
const pending = new Set(summaries.keys());
for (const file of files) {
const basename = path.basename(file);
const sessionId = [...pending].find((id) => basename.includes(id));
if (!sessionId) {
continue;
}
const entry = summaries.get(sessionId);
if (!entry) {
continue;
}
entry.sessionFile = file;
const firstLine = (await readFirstLine(file)) ?? "";
const cwd = readSessionMetaCwd(firstLine);
if (cwd) {
entry.cwd = cwd;
}
pending.delete(sessionId);
if (pending.size === 0) {
return;
}
}
}
async function hydrateSessionsFromSessionFiles(
codexHome: string,
summaries: Map<string, CodexCliSessionSummary>,
): Promise<void> {
const sessionsDir = path.join(codexHome, "sessions");
const files = await findSessionFiles(sessionsDir, 4);
for (const file of files) {
const summary = await readSessionFileSummary(file);
if (!summary) {
continue;
}
const existing = summaries.get(summary.sessionId);
summaries.set(summary.sessionId, {
...summary,
...existing,
cwd: existing?.cwd ?? summary.cwd,
sessionFile: existing?.sessionFile ?? summary.sessionFile,
updatedAt: existing?.updatedAt ?? summary.updatedAt,
lastMessage: existing?.lastMessage ?? summary.lastMessage,
messageCount: existing?.messageCount ?? summary.messageCount,
});
}
}
async function readSessionFileSummary(file: string): Promise<CodexCliSessionSummary | null> {
const content = await readFileIfExists(file);
if (!content) {
return null;
}
let sessionId = "";
let cwd: string | undefined;
let updatedAt: string | undefined;
let lastMessage: string | undefined;
let messageCount = 0;
for (const line of content.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
continue;
}
if (!isRecord(parsed)) {
continue;
}
if (typeof parsed.timestamp === "string" && parsed.timestamp.trim()) {
updatedAt = parsed.timestamp.trim();
}
if (parsed.type === "session_meta" && isRecord(parsed.payload)) {
if (typeof parsed.payload.id === "string" && parsed.payload.id.trim()) {
sessionId = parsed.payload.id.trim();
}
if (typeof parsed.payload.cwd === "string" && parsed.payload.cwd.trim()) {
cwd = parsed.payload.cwd.trim();
}
continue;
}
const messageText = readResponseItemMessageText(parsed);
if (messageText) {
messageCount += 1;
lastMessage = truncateText(messageText, 140);
}
}
if (!sessionId) {
sessionId = readSessionIdFromFilename(file) ?? "";
}
if (!sessionId) {
return null;
}
return {
sessionId,
updatedAt: updatedAt ?? (await readFileMtimeIso(file)),
lastMessage,
cwd,
sessionFile: file,
messageCount,
};
}
async function findSessionFiles(dir: string, maxDepth: number): Promise<string[]> {
if (maxDepth < 0) {
return [];
}
let entries: Array<import("node:fs").Dirent>;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const files: string[] = [];
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await findSessionFiles(entryPath, maxDepth - 1)));
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
files.push(entryPath);
}
}
return files;
}
function readSessionMetaCwd(line: string): string | undefined {
try {
const parsed = JSON.parse(line) as unknown;
if (!isRecord(parsed) || parsed.type !== "session_meta" || !isRecord(parsed.payload)) {
return undefined;
}
return typeof parsed.payload.cwd === "string" && parsed.payload.cwd.trim()
? parsed.payload.cwd.trim()
: undefined;
} catch {
return undefined;
}
}
function readResponseItemMessageText(parsed: Record<string, unknown>): string | undefined {
if (parsed.type !== "response_item" || !isRecord(parsed.payload)) {
return undefined;
}
if (parsed.payload.type !== "message") {
return undefined;
}
const role = typeof parsed.payload.role === "string" ? parsed.payload.role : "";
if (role !== "user") {
return undefined;
}
const content = Array.isArray(parsed.payload.content) ? parsed.payload.content : [];
const parts = content.flatMap((entry) => {
if (!isRecord(entry)) {
return [];
}
const text =
typeof entry.text === "string"
? entry.text
: typeof entry.input_text === "string"
? entry.input_text
: undefined;
return text?.trim() ? [text.trim()] : [];
});
return parts.length > 0 ? parts.join(" ") : undefined;
}
function readSessionIdFromFilename(file: string): string | undefined {
const match = path.basename(file).match(/[0-9a-f]{8}-[0-9a-f-]{27,}/iu);
return match?.[0];
}
async function resolveCodexCliNode(params: {
runtime: PluginRuntime;
requestedNode?: string;
command: string;
}): Promise<CodexCliSessionNodeInfo> {
const list = await params.runtime.nodes.list(
params.requestedNode ? undefined : { connected: true },
);
const requested = params.requestedNode?.trim();
const candidates = list.nodes.filter((node) => {
if (requested) {
return [node.nodeId, node.displayName, node.remoteIp].some((value) => value === requested);
}
return node.connected === true && node.commands?.includes(params.command);
});
if (candidates.length === 0) {
throw new Error(
requested
? `Codex CLI node ${requested} was not found.`
: "No connected node exposes Codex CLI session commands.",
);
}
const usable = candidates.filter((node) => node.commands?.includes(params.command));
if (usable.length === 0) {
throw new Error(`Node ${requested ?? "candidate"} does not expose ${params.command}.`);
}
if (usable.length > 1) {
throw new Error("Multiple Codex CLI-capable nodes connected. Pass --host <node-id>.");
}
return usable[0];
}
function parseCodexCliSessionsListResult(raw: unknown): CodexCliSessionsListResult {
const payload = unwrapNodeInvokePayload(raw);
if (!isRecord(payload) || !Array.isArray(payload.sessions)) {
throw new Error("Codex CLI session list returned an invalid payload.");
}
return {
codexHome: typeof payload.codexHome === "string" ? payload.codexHome : "",
sessions: payload.sessions.flatMap((entry) => {
if (!isRecord(entry) || typeof entry.sessionId !== "string") {
return [];
}
return [
{
sessionId: entry.sessionId,
updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : undefined,
lastMessage: typeof entry.lastMessage === "string" ? entry.lastMessage : undefined,
cwd: typeof entry.cwd === "string" ? entry.cwd : undefined,
sessionFile: typeof entry.sessionFile === "string" ? entry.sessionFile : undefined,
messageCount:
typeof entry.messageCount === "number" && Number.isFinite(entry.messageCount)
? entry.messageCount
: 0,
},
];
}),
};
}
function unwrapNodeInvokePayload(raw: unknown): unknown {
const record = isRecord(raw) ? raw : {};
if (typeof record.payloadJSON === "string" && record.payloadJSON.trim()) {
return JSON.parse(record.payloadJSON) as unknown;
}
if ("payload" in record) {
return record.payload;
}
return raw;
}
function readRecordParam(paramsJSON?: string | null): Record<string, unknown> {
if (!paramsJSON?.trim()) {
return {};
}
try {
const parsed = JSON.parse(paramsJSON) as unknown;
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
function resolveCodexHome(): string {
return process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
}
async function readFileIfExists(file: string): Promise<string | undefined> {
try {
return await fs.readFile(file, "utf8");
} catch {
return undefined;
}
}
async function readFirstLine(file: string): Promise<string | undefined> {
const content = await readFileIfExists(file);
return content?.split(/\r?\n/u)[0];
}
async function readFileMtimeIso(file: string): Promise<string | undefined> {
try {
return (await fs.stat(file)).mtime.toISOString();
} catch {
return undefined;
}
}
function normalizeLimit(value: unknown): number {
return typeof value === "number" && Number.isFinite(value)
? Math.min(MAX_SESSION_LIMIT, Math.max(1, Math.floor(value)))
: DEFAULT_SESSION_LIMIT;
}
function normalizeTimeoutMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.min(60 * 60_000, Math.floor(value))
: DEFAULT_RESUME_TIMEOUT_MS;
}
function truncateText(value: string, max: number): string {
return value.length > max ? `${value.slice(0, max - 3)}...` : value;
}
function compareOptionalStringsDesc(a?: string, b?: string): number {
return (b ?? "").localeCompare(a ?? "");
}
function readNodeId(node: CodexCliSessionNodeInfo): string {
if (!node.nodeId) {
throw new Error("Codex CLI node did not include a node id.");
}
return node.nodeId;
}
function formatNodeLabel(node: CodexCliSessionNodeInfo): string {
return [node.displayName, node.nodeId, node.remoteIp].filter(Boolean).join(" / ") || "node";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}