mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 18:04:45 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
151
extensions/codex/src/node-cli-sessions.test.ts
Normal file
151
extensions/codex/src/node-cli-sessions.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
705
extensions/codex/src/node-cli-sessions.ts
Normal file
705
extensions/codex/src/node-cli-sessions.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user