Skip to content

Commit a5c1956

Browse files
authored
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
1 parent 2268ce3 commit a5c1956

11 files changed

Lines changed: 1342 additions & 20 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ Docs: https://docs.openclaw.ai
288288
- 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.
289289
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
290290
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
291+
- 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.
291292

292293
### Fixes
293294

docs/plugins/codex-harness.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,14 @@ Keep provider refs and runtime policy separate:
177177

178178
Common command routing:
179179

180-
| User intent | Use |
181-
| ------------------------------- | --------------------------------------- |
182-
| Attach the current chat | `/codex bind [--cwd <path>]` |
183-
| Resume an existing Codex thread | `/codex resume <thread-id>` |
184-
| List or filter Codex threads | `/codex threads [filter]` |
185-
| Send Codex feedback only | `/codex diagnostics [note]` |
186-
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
180+
| User intent | Use |
181+
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
182+
| Attach the current chat | `/codex bind [--cwd <path>]` |
183+
| Resume an existing Codex thread | `/codex resume <thread-id>` |
184+
| List or filter Codex threads | `/codex threads [filter]` |
185+
| Attach an existing Codex CLI session on a paired node | `/codex sessions --host <node> [filter]`, then `/codex resume <session-id> --host <node> --bind here` |
186+
| Send Codex feedback only | `/codex diagnostics [note]` |
187+
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
187188

188189
| Use case | Configure | Verify | Notes |
189190
| ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------- | ---------------------------------- |

extensions/codex/index.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import {
1010
handleCodexConversationInboundClaim,
1111
} from "./src/conversation-binding.js";
1212
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
13+
import {
14+
createCodexCliSessionNodeHostCommands,
15+
createCodexCliSessionNodeInvokePolicies,
16+
listCodexCliSessionsOnNode,
17+
resumeCodexCliSessionOnNode,
18+
resolveCodexCliSessionForBindingOnNode,
19+
} from "./src/node-cli-sessions.js";
1320

1421
export default definePluginEntry({
1522
id: "codex",
@@ -30,10 +37,28 @@ export default definePluginEntry({
3037
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
3138
);
3239
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
33-
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
40+
for (const command of createCodexCliSessionNodeHostCommands()) {
41+
api.registerNodeHostCommand(command);
42+
}
43+
for (const policy of createCodexCliSessionNodeInvokePolicies()) {
44+
api.registerNodeInvokePolicy(policy);
45+
}
46+
api.registerCommand(
47+
createCodexCommand({
48+
pluginConfig: api.pluginConfig,
49+
deps: {
50+
listCodexCliSessionsOnNode: (params) =>
51+
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
52+
resolveCodexCliSessionForBindingOnNode: (params) =>
53+
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
54+
},
55+
}),
56+
);
3457
api.on("inbound_claim", (event, ctx) =>
3558
handleCodexConversationInboundClaim(event, ctx, {
3659
pluginConfig: resolveCurrentPluginConfig(),
60+
resumeCodexCliSessionOnNode: (params) =>
61+
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
3762
}),
3863
);
3964
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);

extensions/codex/src/command-formatters.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,9 @@ export function buildHelp(): string {
301301
"- /codex status",
302302
"- /codex models",
303303
"- /codex threads [filter]",
304+
"- /codex sessions --host <node> [filter]",
304305
"- /codex resume <thread-id>",
306+
"- /codex resume <session-id> --host <node> --bind here",
305307
"- /codex bind [thread-id] [--cwd <path>] [--model <model>] [--provider <provider>]",
306308
"- /codex binding",
307309
"- /codex stop",

extensions/codex/src/command-handlers.ts

Lines changed: 197 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
type SafeValue,
3737
} from "./command-rpc.js";
3838
import {
39+
createCodexCliNodeConversationBindingData,
3940
readCodexConversationBindingData,
4041
resolveCodexDefaultWorkspaceDir,
4142
startCodexConversationThread,
@@ -51,6 +52,11 @@ import {
5152
steerCodexConversationTurn,
5253
stopCodexConversationTurn,
5354
} from "./conversation-control.js";
55+
import {
56+
formatCodexCliSessions,
57+
listCodexCliSessionsOnNode,
58+
resolveCodexCliSessionForBindingOnNode,
59+
} from "./node-cli-sessions.js";
5460

5561
export type CodexCommandDeps = {
5662
codexControlRequest: CodexControlRequestFn;
@@ -71,6 +77,8 @@ export type CodexCommandDeps = {
7177
setCodexConversationPermissions: typeof setCodexConversationPermissions;
7278
steerCodexConversationTurn: typeof steerCodexConversationTurn;
7379
stopCodexConversationTurn: typeof stopCodexConversationTurn;
80+
listCodexCliSessionsOnNode: ListCodexCliSessionsOnNodeFn;
81+
resolveCodexCliSessionForBindingOnNode: ResolveCodexCliSessionForBindingOnNodeFn;
7482
};
7583

7684
type CodexControlRequestFn = (
@@ -87,6 +95,14 @@ type SafeCodexControlRequestFn = (
8795
options?: CodexControlRequestOptions,
8896
) => Promise<SafeValue<JsonValue | undefined>>;
8997

98+
type ListCodexCliSessionsOnNodeFn = (
99+
params: Omit<Parameters<typeof listCodexCliSessionsOnNode>[0], "runtime">,
100+
) => ReturnType<typeof listCodexCliSessionsOnNode>;
101+
102+
type ResolveCodexCliSessionForBindingOnNodeFn = (
103+
params: Omit<Parameters<typeof resolveCodexCliSessionForBindingOnNode>[0], "runtime">,
104+
) => ReturnType<typeof resolveCodexCliSessionForBindingOnNode>;
105+
90106
const defaultCodexCommandDeps: CodexCommandDeps = {
91107
codexControlRequest,
92108
listCodexAppServerModels: listAllCodexAppServerModels,
@@ -106,6 +122,12 @@ const defaultCodexCommandDeps: CodexCommandDeps = {
106122
setCodexConversationPermissions,
107123
steerCodexConversationTurn,
108124
stopCodexConversationTurn,
125+
listCodexCliSessionsOnNode: async () => {
126+
throw new Error("Codex CLI node sessions require Gateway node runtime.");
127+
},
128+
resolveCodexCliSessionForBindingOnNode: async () => {
129+
throw new Error("Codex CLI node sessions require Gateway node runtime.");
130+
},
109131
};
110132

111133
type ParsedBindArgs = {
@@ -123,6 +145,20 @@ type ParsedComputerUseArgs = {
123145
help?: boolean;
124146
};
125147

148+
type ParsedCodexCliSessionsArgs = {
149+
host?: string;
150+
filter: string;
151+
limit?: number;
152+
help?: boolean;
153+
};
154+
155+
type ParsedResumeArgs = {
156+
threadId?: string;
157+
host?: string;
158+
bindHere?: boolean;
159+
help?: boolean;
160+
};
161+
126162
type ParsedDiagnosticsArgs =
127163
| { action: "request"; note: string }
128164
| { action: "confirm"; token: string }
@@ -214,6 +250,9 @@ export async function handleCodexSubcommand(
214250
if (normalized === "threads") {
215251
return { text: await buildThreads(deps, options.pluginConfig, rest.join(" ")) };
216252
}
253+
if (normalized === "sessions") {
254+
return { text: await buildCodexCliSessions(deps, rest) };
255+
}
217256
if (normalized === "resume") {
218257
return { text: await resumeThread(deps, ctx, options.pluginConfig, rest) };
219258
}
@@ -437,7 +476,7 @@ async function detachConversation(
437476
const current = await ctx.getCurrentConversationBinding();
438477
const data = readCodexConversationBindingData(current);
439478
const detached = await ctx.detachConversationBinding();
440-
if (data) {
479+
if (data?.kind === "codex-app-server-session") {
441480
await deps.clearCodexAppServerBinding(data.sessionFile);
442481
} else if (ctx.sessionFile) {
443482
await deps.clearCodexAppServerBinding(ctx.sessionFile);
@@ -456,6 +495,16 @@ async function describeConversationBinding(
456495
if (!current || !data) {
457496
return "No Codex conversation binding is attached.";
458497
}
498+
if (data.kind === "codex-cli-node-session") {
499+
return [
500+
"Codex conversation binding:",
501+
"- Mode: Codex CLI node session",
502+
`- Node: ${formatCodexDisplayText(data.nodeId)}`,
503+
`- Session: ${formatCodexDisplayText(data.sessionId)}`,
504+
`- Workspace: ${formatCodexDisplayText(data.cwd ?? "unknown")}`,
505+
"- Active run: not tracked",
506+
].join("\n");
507+
}
459508
const threadBinding = await deps.readCodexAppServerBinding(data.sessionFile);
460509
const active = deps.readCodexConversationActiveTurn(data.sessionFile);
461510
return [
@@ -482,14 +531,36 @@ async function buildThreads(
482531
return formatThreads(response);
483532
}
484533

534+
async function buildCodexCliSessions(deps: CodexCommandDeps, args: string[]): Promise<string> {
535+
const parsed = parseCodexCliSessionsArgs(args);
536+
if (parsed.help || !parsed.host) {
537+
return "Usage: /codex sessions --host <node> [filter] [--limit <n>]";
538+
}
539+
return formatCodexCliSessions(
540+
await deps.listCodexCliSessionsOnNode({
541+
requestedNode: parsed.host,
542+
filter: parsed.filter,
543+
limit: parsed.limit,
544+
}),
545+
);
546+
}
547+
485548
async function resumeThread(
486549
deps: CodexCommandDeps,
487550
ctx: PluginCommandContext,
488551
pluginConfig: unknown,
489552
args: string[],
490553
): Promise<string> {
491-
const [threadId] = args;
492-
const normalizedThreadId = threadId?.trim();
554+
const parsed = parseResumeArgs(args);
555+
const normalizedThreadId = parsed.threadId?.trim();
556+
if (parsed.help) {
557+
return args.includes("--help") || args.includes("-h") || parsed.host
558+
? "Usage: /codex resume <thread-id>\nUsage: /codex resume <session-id> --host <node> --bind here"
559+
: "Usage: /codex resume <thread-id>";
560+
}
561+
if (parsed.host) {
562+
return await bindCodexCliNodeSession(deps, ctx, parsed);
563+
}
493564
if (!normalizedThreadId || args.length !== 1) {
494565
return "Usage: /codex resume <thread-id>";
495566
}
@@ -517,6 +588,47 @@ async function resumeThread(
517588
)}.`;
518589
}
519590

591+
async function bindCodexCliNodeSession(
592+
deps: CodexCommandDeps,
593+
ctx: PluginCommandContext,
594+
parsed: ParsedResumeArgs,
595+
): Promise<string> {
596+
if (!parsed.threadId || !parsed.host || parsed.bindHere !== true) {
597+
return "Usage: /codex resume <session-id> --host <node> --bind here";
598+
}
599+
const resolved = await deps.resolveCodexCliSessionForBindingOnNode({
600+
requestedNode: parsed.host,
601+
sessionId: parsed.threadId,
602+
});
603+
if (!resolved.session) {
604+
return `No Codex CLI session ${formatCodexDisplayText(parsed.threadId)} was found on ${formatCodexDisplayText(parsed.host)}.`;
605+
}
606+
const nodeId = resolved.node.nodeId;
607+
if (!nodeId) {
608+
return "Cannot bind Codex CLI session because the selected node did not include a node id.";
609+
}
610+
const data = createCodexCliNodeConversationBindingData({
611+
nodeId,
612+
sessionId: parsed.threadId,
613+
cwd: resolved.session?.cwd,
614+
});
615+
const summary = `Codex CLI session ${formatCodexDisplayText(parsed.threadId)} on ${formatCodexDisplayText(nodeId)}`;
616+
const request = await ctx.requestConversationBinding({
617+
summary,
618+
detachHint: "/codex detach",
619+
data,
620+
});
621+
if (request.status === "bound") {
622+
return `Bound this conversation to Codex CLI session ${formatCodexDisplayText(
623+
parsed.threadId,
624+
)} on ${formatCodexDisplayText(nodeId)}.`;
625+
}
626+
if (request.status === "pending") {
627+
return request.reply.text ?? "Codex CLI session binding is pending approval.";
628+
}
629+
return formatCodexDisplayText(request.message);
630+
}
631+
520632
async function stopConversationTurn(
521633
deps: CodexCommandDeps,
522634
ctx: PluginCommandContext,
@@ -628,7 +740,8 @@ async function setConversationPermissions(
628740

629741
async function resolveControlSessionFile(ctx: PluginCommandContext): Promise<string | undefined> {
630742
const binding = await ctx.getCurrentConversationBinding();
631-
return readCodexConversationBindingData(binding)?.sessionFile ?? ctx.sessionFile;
743+
const data = readCodexConversationBindingData(binding);
744+
return data?.kind === "codex-app-server-session" ? data.sessionFile : ctx.sessionFile;
632745
}
633746

634747
async function handleCodexDiagnosticsFeedback(
@@ -1613,6 +1726,86 @@ function parseBindArgs(args: string[]): ParsedBindArgs {
16131726
return parsed;
16141727
}
16151728

1729+
function parseCodexCliSessionsArgs(args: string[]): ParsedCodexCliSessionsArgs {
1730+
const parsed: ParsedCodexCliSessionsArgs = { filter: "" };
1731+
const filter: string[] = [];
1732+
for (let index = 0; index < args.length; index += 1) {
1733+
const arg = args[index];
1734+
if (arg === "--help" || arg === "-h") {
1735+
parsed.help = true;
1736+
continue;
1737+
}
1738+
if (arg === "--host" || arg === "--node") {
1739+
const value = readRequiredOptionValue(args, index);
1740+
if (!value || parsed.host !== undefined) {
1741+
parsed.help = true;
1742+
continue;
1743+
}
1744+
parsed.host = value;
1745+
index += 1;
1746+
continue;
1747+
}
1748+
if (arg === "--limit") {
1749+
const value = readRequiredOptionValue(args, index);
1750+
const parsedLimit = value ? Number.parseInt(value, 10) : Number.NaN;
1751+
if (!Number.isFinite(parsedLimit) || parsedLimit <= 0) {
1752+
parsed.help = true;
1753+
continue;
1754+
}
1755+
parsed.limit = parsedLimit;
1756+
index += 1;
1757+
continue;
1758+
}
1759+
if (arg.startsWith("-")) {
1760+
parsed.help = true;
1761+
continue;
1762+
}
1763+
filter.push(arg);
1764+
}
1765+
parsed.host = normalizeOptionalString(parsed.host);
1766+
parsed.filter = filter.join(" ").trim();
1767+
return parsed;
1768+
}
1769+
1770+
function parseResumeArgs(args: string[]): ParsedResumeArgs {
1771+
const parsed: ParsedResumeArgs = {};
1772+
for (let index = 0; index < args.length; index += 1) {
1773+
const arg = args[index];
1774+
if (arg === "--help" || arg === "-h") {
1775+
parsed.help = true;
1776+
continue;
1777+
}
1778+
if (arg === "--host" || arg === "--node") {
1779+
const value = readRequiredOptionValue(args, index);
1780+
if (!value || parsed.host !== undefined) {
1781+
parsed.help = true;
1782+
continue;
1783+
}
1784+
parsed.host = value;
1785+
index += 1;
1786+
continue;
1787+
}
1788+
if (arg === "--bind") {
1789+
const value = readRequiredOptionValue(args, index);
1790+
if (value !== "here" || parsed.bindHere !== undefined) {
1791+
parsed.help = true;
1792+
continue;
1793+
}
1794+
parsed.bindHere = true;
1795+
index += 1;
1796+
continue;
1797+
}
1798+
if (!arg.startsWith("-") && !parsed.threadId) {
1799+
parsed.threadId = arg;
1800+
continue;
1801+
}
1802+
parsed.help = true;
1803+
}
1804+
parsed.threadId = normalizeOptionalString(parsed.threadId);
1805+
parsed.host = normalizeOptionalString(parsed.host);
1806+
return parsed;
1807+
}
1808+
16161809
function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs {
16171810
const parsed: ParsedComputerUseArgs = {
16181811
action: "status",

0 commit comments

Comments
 (0)