From a5c1956ca17a84475f3c4552deb10c737d5ea35e Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Thu, 14 May 2026 11:24:30 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/plugins/codex-harness.md | 15 +- extensions/codex/index.ts | 27 +- extensions/codex/src/command-formatters.ts | 2 + extensions/codex/src/command-handlers.ts | 201 ++++- extensions/codex/src/commands.test.ts | 110 +++ .../codex/src/conversation-binding-data.ts | 53 +- .../codex/src/conversation-binding.test.ts | 49 ++ extensions/codex/src/conversation-binding.ts | 48 +- .../codex/src/node-cli-sessions.test.ts | 151 ++++ extensions/codex/src/node-cli-sessions.ts | 705 ++++++++++++++++++ 11 files changed, 1342 insertions(+), 20 deletions(-) create mode 100644 extensions/codex/src/node-cli-sessions.test.ts create mode 100644 extensions/codex/src/node-cli-sessions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ec993b90ca4..eb27b6368af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index b4eb07e3ba6..767486f0fa7 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -177,13 +177,14 @@ Keep provider refs and runtime policy separate: Common command routing: -| User intent | Use | -| ------------------------------- | --------------------------------------- | -| Attach the current chat | `/codex bind [--cwd ]` | -| Resume an existing Codex thread | `/codex resume ` | -| 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 ]` | +| Resume an existing Codex thread | `/codex resume ` | +| List or filter Codex threads | `/codex threads [filter]` | +| Attach an existing Codex CLI session on a paired node | `/codex sessions --host [filter]`, then `/codex resume --host --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 | | ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------- | ---------------------------------- | diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index f8615bb2178..795b000d59e 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -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); diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 5480725a7f7..ea48018e7f9 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -301,7 +301,9 @@ export function buildHelp(): string { "- /codex status", "- /codex models", "- /codex threads [filter]", + "- /codex sessions --host [filter]", "- /codex resume ", + "- /codex resume --host --bind here", "- /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", "- /codex binding", "- /codex stop", diff --git a/extensions/codex/src/command-handlers.ts b/extensions/codex/src/command-handlers.ts index b203f140180..c02bf7e93dc 100644 --- a/extensions/codex/src/command-handlers.ts +++ b/extensions/codex/src/command-handlers.ts @@ -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>; +type ListCodexCliSessionsOnNodeFn = ( + params: Omit[0], "runtime">, +) => ReturnType; + +type ResolveCodexCliSessionForBindingOnNodeFn = ( + params: Omit[0], "runtime">, +) => ReturnType; + 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 { + const parsed = parseCodexCliSessionsArgs(args); + if (parsed.help || !parsed.host) { + return "Usage: /codex sessions --host [filter] [--limit ]"; + } + 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 { - 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 \nUsage: /codex resume --host --bind here" + : "Usage: /codex resume "; + } + if (parsed.host) { + return await bindCodexCliNodeSession(deps, ctx, parsed); + } if (!normalizedThreadId || args.length !== 1) { return "Usage: /codex resume "; } @@ -517,6 +588,47 @@ async function resumeThread( )}.`; } +async function bindCodexCliNodeSession( + deps: CodexCommandDeps, + ctx: PluginCommandContext, + parsed: ParsedResumeArgs, +): Promise { + if (!parsed.threadId || !parsed.host || parsed.bindHere !== true) { + return "Usage: /codex resume --host --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 { 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", diff --git a/extensions/codex/src/commands.test.ts b/extensions/codex/src/commands.test.ts index 0d7f4830740..41761a92ce8 100644 --- a/extensions/codex/src/commands.test.ts +++ b/extensions/codex/src/commands.test.ts @@ -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)"; diff --git a/extensions/codex/src/conversation-binding-data.ts b/extensions/codex/src/conversation-binding-data.ts index bbf4443efd6..edacc646785 100644 --- a/extensions/codex/src/conversation-binding-data.ts +++ b/extensions/codex/src/conversation-binding-data.ts @@ -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, ): 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() diff --git a/extensions/codex/src/conversation-binding.test.ts b/extensions/codex/src/conversation-binding.test.ts index 217268de0bc..45f75ae55c4 100644 --- a/extensions/codex/src/conversation-binding.test.ts +++ b/extensions/codex/src/conversation-binding.test.ts @@ -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({ diff --git a/extensions/codex/src/conversation-binding.ts b/extensions/codex/src/conversation-binding.ts index b3c72d871b6..f9229935814 100644 --- a/extensions/codex/src/conversation-binding.ts +++ b/extensions/codex/src/conversation-binding.ts @@ -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[0], "runtime">, +) => ReturnType; + type CodexConversationStartParams = { pluginConfig?: unknown; config?: Parameters[0]["config"]; @@ -87,7 +94,7 @@ function getGlobalState(): CodexConversationGlobalState { export async function startCodexConversationThread( params: CodexConversationStartParams, -): Promise { +): Promise { 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; diff --git a/extensions/codex/src/node-cli-sessions.test.ts b/extensions/codex/src/node-cli-sessions.test.ts new file mode 100644 index 00000000000..afc710f9ef3 --- /dev/null +++ b/extensions/codex/src/node-cli-sessions.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/codex/src/node-cli-sessions.ts b/extensions/codex/src/node-cli-sessions.ts new file mode 100644 index 00000000000..957bf0498ea --- /dev/null +++ b/extensions/codex/src/node-cli-sessions.ts @@ -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(); + +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 { + 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 { + 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 { + 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 { + 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((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> { + const summaries = new Map(); + 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, +): Promise { + 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, +): Promise { + 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 { + 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 { + if (maxDepth < 0) { + return []; + } + let entries: Array; + 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 | 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 { + 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 ."); + } + 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 { + 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 { + try { + return await fs.readFile(file, "utf8"); + } catch { + return undefined; + } +} + +async function readFirstLine(file: string): Promise { + const content = await readFileIfExists(file); + return content?.split(/\r?\n/u)[0]; +} + +async function readFileMtimeIso(file: string): Promise { + 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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +}