feat: add Codex app-server controls

This commit is contained in:
Peter Steinberger
2026-04-10 22:18:23 +01:00
parent 0f0891656b
commit 31a0b7bd42
22 changed files with 1162 additions and 93 deletions

View File

@@ -255,6 +255,106 @@ fallback catalog:
}
```
## App-server connection and policy
By default, the plugin starts Codex locally with:
```bash
codex app-server --listen stdio://
```
You can keep that default and only tune Codex native policy:
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: {
approvalPolicy: "on-request",
sandbox: "workspace-write",
serviceTier: "priority",
},
},
},
},
},
}
```
For an already-running app-server, use WebSocket transport:
```json5
{
plugins: {
entries: {
codex: {
enabled: true,
config: {
appServer: {
transport: "websocket",
url: "ws://127.0.0.1:39175",
authToken: "${CODEX_APP_SERVER_TOKEN}",
requestTimeoutMs: 60000,
},
},
},
},
},
}
```
Supported `appServer` fields:
| Field | Default | Meaning |
| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------ |
| `transport` | `"stdio"` | `"stdio"` spawns Codex; `"websocket"` connects to `url`. |
| `command` | `"codex"` | Executable for stdio transport. |
| `args` | `["app-server", "--listen", "stdio://"]` | Arguments for stdio transport. |
| `url` | unset | WebSocket app-server URL. |
| `authToken` | unset | Bearer token for WebSocket transport. |
| `headers` | `{}` | Extra WebSocket headers. |
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
| `approvalPolicy` | `"never"` | Native Codex approval policy sent to thread start/resume/turn. |
| `sandbox` | `"workspace-write"` | Native Codex sandbox mode sent to thread start/resume. |
| `approvalsReviewer` | `"user"` | Use `"guardian_subagent"` to let Codex guardian review native approvals. |
| `serviceTier` | unset | Optional Codex service tier, for example `"priority"`. |
The older environment variables still work as fallbacks for local testing when
the matching config field is unset:
- `OPENCLAW_CODEX_APP_SERVER_BIN`
- `OPENCLAW_CODEX_APP_SERVER_ARGS`
- `OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY`
- `OPENCLAW_CODEX_APP_SERVER_SANDBOX`
- `OPENCLAW_CODEX_APP_SERVER_GUARDIAN=1`
Config is preferred for repeatable deployments.
## Codex command
The bundled plugin registers `/codex` as an authorized slash command. It is
generic and works on any channel that supports OpenClaw text commands.
Common forms:
- `/codex status` shows live app-server connectivity, models, account, rate limits, MCP servers, and skills.
- `/codex models` lists live Codex app-server models.
- `/codex threads [filter]` lists recent Codex threads.
- `/codex resume <thread-id>` attaches the current OpenClaw session to an existing Codex thread.
- `/codex compact` asks Codex app-server to compact the attached thread.
- `/codex review` starts Codex native review for the attached thread.
- `/codex account` shows account and rate-limit status.
- `/codex mcp` lists Codex app-server MCP server status.
- `/codex skills` lists Codex app-server skills.
`/codex resume` writes the same sidecar binding file that the harness uses for
normal turns. On the next message, OpenClaw resumes that Codex thread, passes the
currently selected OpenClaw `codex/*` model into app-server, and keeps extended
history enabled.
## Tools, media, and compaction
The Codex harness changes the low-level embedded agent executor only.
@@ -286,6 +386,9 @@ reports version `0.118.0` or newer.
**Model discovery is slow:** lower `plugins.entries.codex.config.discovery.timeoutMs`
or disable discovery.
**WebSocket transport fails immediately:** check `appServer.url`, `authToken`,
and that the remote app-server speaks the same Codex app-server protocol version.
**A non-Codex model uses PI:** that is expected. The Codex harness only claims
`codex/*` model refs.

View File

@@ -152,6 +152,7 @@ Bundled plugins can add more slash commands. Current bundled commands in this re
- `/phone status|arm <camera|screen|writes|all> [duration]|disarm` temporarily arms high-risk phone node commands.
- `/voice status|list [limit]|set <voiceId|name>` manages Talk voice config. On Discord, the native command name is `/talkvoice`.
- `/card ...` sends LINE rich card presets. See [LINE](/channels/line).
- `/codex status|models|threads|resume|compact|review|account|mcp|skills` inspects and controls the bundled Codex app-server harness. See [Codex Harness](/plugins/codex-harness).
- QQBot-only commands:
- `/bot-ping`
- `/bot-version`

View File

@@ -18,6 +18,7 @@ export function createCodexAppServerAgentHarness(options?: {
id?: string;
label?: string;
providerIds?: Iterable<string>;
pluginConfig?: unknown;
}): AgentHarness {
const providerIds = new Set(
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
@@ -37,8 +38,10 @@ export function createCodexAppServerAgentHarness(options?: {
reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`,
};
},
runAttempt: runCodexAppServerAttempt,
compact: maybeCompactCodexAppServerSession,
runAttempt: (params) =>
runCodexAppServerAttempt(params, { pluginConfig: options?.pluginConfig }),
compact: (params) =>
maybeCompactCodexAppServerSession(params, { pluginConfig: options?.pluginConfig }),
reset: async (params) => {
if (params.sessionFile) {
await clearCodexAppServerBinding(params.sessionFile);

View File

@@ -14,6 +14,7 @@ describe("codex plugin", () => {
it("registers the codex provider and agent harness", () => {
const registerAgentHarness = vi.fn();
const registerCommand = vi.fn();
const registerProvider = vi.fn();
plugin.register(
@@ -25,6 +26,7 @@ describe("codex plugin", () => {
pluginConfig: {},
runtime: {} as never,
registerAgentHarness,
registerCommand,
registerProvider,
}),
);
@@ -34,5 +36,9 @@ describe("codex plugin", () => {
id: "codex",
label: "Codex agent harness",
});
expect(registerCommand.mock.calls[0]?.[0]).toMatchObject({
name: "codex",
description: "Inspect and control the Codex app-server harness",
});
});
});

View File

@@ -1,13 +1,15 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createCodexAppServerAgentHarness } from "./harness.js";
import { buildCodexProvider } from "./provider.js";
import { createCodexCommand } from "./src/commands.js";
export default definePluginEntry({
id: "codex",
name: "Codex",
description: "Codex app-server harness and Codex-managed GPT model catalog.",
register(api) {
api.registerAgentHarness(createCodexAppServerAgentHarness());
api.registerAgentHarness(createCodexAppServerAgentHarness({ pluginConfig: api.pluginConfig }));
api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig }));
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
},
});

View File

@@ -3,6 +3,13 @@
"name": "Codex",
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
"providers": ["codex"],
"commandAliases": [
{
"name": "codex",
"kind": "runtime-slash",
"cliCommand": "plugins"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
@@ -18,6 +25,57 @@
"default": 2500
}
}
},
"appServer": {
"type": "object",
"additionalProperties": false,
"properties": {
"transport": {
"type": "string",
"enum": ["stdio", "websocket"],
"default": "stdio"
},
"command": {
"type": "string",
"default": "codex"
},
"args": {
"oneOf": [
{
"type": "array",
"items": { "type": "string" }
},
{ "type": "string" }
]
},
"url": { "type": "string" },
"authToken": { "type": "string" },
"headers": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"requestTimeoutMs": {
"type": "number",
"minimum": 1,
"default": 60000
},
"approvalPolicy": {
"type": "string",
"enum": ["never", "on-request", "on-failure", "untrusted"],
"default": "never"
},
"sandbox": {
"type": "string",
"enum": ["read-only", "workspace-write", "danger-full-access"],
"default": "workspace-write"
},
"approvalsReviewer": {
"type": "string",
"enum": ["user", "guardian_subagent"],
"default": "user"
},
"serviceTier": { "type": "string" }
}
}
}
},
@@ -34,6 +92,62 @@
"label": "Discovery Timeout",
"help": "Maximum time to wait for Codex app-server model discovery before falling back to the bundled model list.",
"advanced": true
},
"appServer": {
"label": "App Server",
"help": "Runtime controls for connecting to Codex app-server.",
"advanced": true
},
"appServer.transport": {
"label": "Transport",
"help": "Use stdio to spawn Codex locally, or websocket to connect to an already-running app-server.",
"advanced": true
},
"appServer.command": {
"label": "Command",
"help": "Executable used for stdio transport.",
"advanced": true
},
"appServer.args": {
"label": "Arguments",
"help": "Arguments used for stdio transport. Defaults to app-server --listen stdio://.",
"advanced": true
},
"appServer.url": {
"label": "WebSocket URL",
"help": "Codex app-server WebSocket URL when transport is websocket.",
"advanced": true
},
"appServer.authToken": {
"label": "Auth Token",
"help": "Bearer token sent to the WebSocket app-server.",
"sensitive": true,
"advanced": true
},
"appServer.requestTimeoutMs": {
"label": "Request Timeout",
"help": "Maximum time to wait for Codex app-server control-plane requests.",
"advanced": true
},
"appServer.approvalPolicy": {
"label": "Approval Policy",
"help": "Codex native approval policy sent to thread start, resume, and turns.",
"advanced": true
},
"appServer.sandbox": {
"label": "Sandbox",
"help": "Codex native sandbox mode sent to thread start and resume.",
"advanced": true
},
"appServer.approvalsReviewer": {
"label": "Approvals Reviewer",
"help": "Use user approvals or the Codex guardian subagent for native app-server approvals.",
"advanced": true
},
"appServer.serviceTier": {
"label": "Service Tier",
"help": "Optional Codex service tier passed when starting or resuming threads.",
"advanced": true
}
}
}

View File

@@ -4,7 +4,8 @@
"description": "OpenClaw Codex harness and model provider plugin",
"type": "module",
"dependencies": {
"@mariozechner/pi-coding-agent": "0.65.2"
"@mariozechner/pi-coding-agent": "0.65.2",
"ws": "^8.20.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -29,7 +29,9 @@ describe("codex provider", () => {
pluginConfig: { discovery: { timeoutMs: 1234 } },
});
expect(listModels).toHaveBeenCalledWith({ limit: 100, timeoutMs: 1234 });
expect(listModels).toHaveBeenCalledWith(
expect.objectContaining({ limit: 100, timeoutMs: 1234 }),
);
expect(result.provider).toMatchObject({
auth: "token",
api: "openai-codex-responses",

View File

@@ -10,6 +10,11 @@ import {
type CodexAppServerModel,
type CodexAppServerModelListResult,
} from "./harness.js";
import {
type CodexAppServerStartOptions,
readCodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
} from "./src/app-server/config.js";
const PROVIDER_ID = "codex";
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
@@ -18,16 +23,10 @@ const DEFAULT_MAX_TOKENS = 128_000;
const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500;
const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE";
type CodexPluginConfig = {
discovery?: {
enabled?: boolean;
timeoutMs?: number;
};
};
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
startOptions?: CodexAppServerStartOptions;
}) => Promise<CodexAppServerModelListResult>;
type BuildCodexProviderOptions = {
@@ -98,6 +97,7 @@ export async function buildCodexProviderCatalog(
options: BuildCatalogOptions = {},
): Promise<{ provider: ModelProviderConfig }> {
const config = readCodexPluginConfig(options.pluginConfig);
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs);
const discovered =
config.discovery?.enabled === false || shouldSkipLiveDiscovery(options.env)
@@ -105,6 +105,7 @@ export async function buildCodexProviderCatalog(
: await listModelsBestEffort({
listModels: options.listModels ?? listCodexAppServerModels,
timeoutMs,
startOptions: appServer.start,
});
const models = (discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS).map(
codexModelToDefinition,
@@ -167,11 +168,13 @@ function buildModelDefinition(model: {
async function listModelsBestEffort(params: {
listModels: CodexModelLister;
timeoutMs: number;
startOptions: CodexAppServerStartOptions;
}): Promise<CodexAppServerModel[]> {
try {
const result = await params.listModels({
timeoutMs: params.timeoutMs,
limit: 100,
startOptions: params.startOptions,
});
return result.models.filter((model) => !model.hidden);
} catch {
@@ -179,13 +182,6 @@ async function listModelsBestEffort(params: {
}
}
function readCodexPluginConfig(value: unknown): CodexPluginConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as CodexPluginConfig;
}
function normalizeTimeoutMs(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value

View File

@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer, type RawData } from "ws";
import {
CodexAppServerClient,
listCodexAppServerModels,
@@ -242,4 +243,53 @@ describe("CodexAppServerClient", () => {
});
startSpy.mockRestore();
});
it("can speak JSON-RPC over websocket transport", async () => {
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
const authHeaders: Array<string | undefined> = [];
server.on("connection", (socket, request) => {
authHeaders.push(request.headers.authorization);
socket.on("message", (data) => {
const message = JSON.parse(rawDataToText(data)) as { id?: number; method?: string };
if (message.method === "initialize") {
socket.send(
JSON.stringify({ id: message.id, result: { userAgent: "openclaw/0.118.0" } }),
);
return;
}
if (message.method === "model/list") {
socket.send(JSON.stringify({ id: message.id, result: { data: [] } }));
}
});
});
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected websocket test server port");
}
const client = CodexAppServerClient.start({
transport: "websocket",
url: `ws://127.0.0.1:${address.port}`,
authToken: "secret",
});
clients.push(client);
await expect(client.initialize()).resolves.toBeUndefined();
await expect(client.request("model/list", {})).resolves.toEqual({ data: [] });
expect(authHeaders).toEqual(["Bearer secret"]);
client.close();
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
});
});
function rawDataToText(data: RawData): string {
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}

View File

@@ -1,6 +1,14 @@
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
import { PassThrough, Writable } from "node:stream";
import { embeddedAgentLog, OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness";
import WebSocket, { type RawData } from "ws";
import {
codexAppServerStartOptionsKey,
resolveCodexAppServerRuntimeOptions,
type CodexAppServerStartOptions,
} from "./config.js";
import {
type CodexInitializeResponse,
isRpcResponse,
@@ -59,6 +67,7 @@ export type CodexAppServerListModelsOptions = {
cursor?: string;
includeHidden?: boolean;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
};
export class CodexAppServerClient {
@@ -93,11 +102,17 @@ export class CodexAppServerClient {
});
}
static start(): CodexAppServerClient {
const bin = process.env.OPENCLAW_CODEX_APP_SERVER_BIN?.trim() || "codex";
const extraArgs = splitShellWords(process.env.OPENCLAW_CODEX_APP_SERVER_ARGS ?? "");
const args = extraArgs.length > 0 ? extraArgs : ["app-server", "--listen", "stdio://"];
const child = spawn(bin, args, {
static start(options?: Partial<CodexAppServerStartOptions>): CodexAppServerClient {
const defaults = resolveCodexAppServerRuntimeOptions().start;
const startOptions = {
...defaults,
...options,
headers: options?.headers ?? defaults.headers,
};
if (startOptions.transport === "websocket") {
return new CodexAppServerClient(createWebSocketTransport(startOptions));
}
const child = spawn(startOptions.command, startOptions.args, {
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
@@ -266,10 +281,19 @@ export class CodexAppServerClient {
let sharedClient: CodexAppServerClient | undefined;
let sharedClientPromise: Promise<CodexAppServerClient> | undefined;
let sharedClientKey: string | undefined;
export async function getSharedCodexAppServerClient(): Promise<CodexAppServerClient> {
export async function getSharedCodexAppServerClient(options?: {
startOptions?: CodexAppServerStartOptions;
}): Promise<CodexAppServerClient> {
const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const key = codexAppServerStartOptionsKey(startOptions);
if (sharedClientKey && sharedClientKey !== key) {
clearSharedCodexAppServerClient();
}
sharedClientKey = key;
sharedClientPromise ??= (async () => {
const client = CodexAppServerClient.start();
const client = CodexAppServerClient.start(startOptions);
sharedClient = client;
try {
await client.initialize();
@@ -286,6 +310,7 @@ export async function getSharedCodexAppServerClient(): Promise<CodexAppServerCli
} catch (error) {
sharedClient = undefined;
sharedClientPromise = undefined;
sharedClientKey = undefined;
throw error;
}
}
@@ -293,12 +318,14 @@ export async function getSharedCodexAppServerClient(): Promise<CodexAppServerCli
export function resetSharedCodexAppServerClientForTests(): void {
sharedClient = undefined;
sharedClientPromise = undefined;
sharedClientKey = undefined;
}
export function clearSharedCodexAppServerClient(): void {
const client = sharedClient;
sharedClient = undefined;
sharedClientPromise = undefined;
sharedClientKey = undefined;
client?.close();
}
@@ -308,6 +335,7 @@ function clearSharedClientIfCurrent(client: CodexAppServerClient): void {
}
sharedClient = undefined;
sharedClientPromise = undefined;
sharedClientKey = undefined;
}
export async function listCodexAppServerModels(
@@ -316,7 +344,7 @@ export async function listCodexAppServerModels(
const timeoutMs = options.timeoutMs ?? 2500;
return await withTimeout(
(async () => {
const client = await getSharedCodexAppServerClient();
const client = await getSharedCodexAppServerClient({ startOptions: options.startOptions });
const response = await client.request<JsonObject>("model/list", {
limit: options.limit ?? null,
cursor: options.cursor ?? null,
@@ -329,6 +357,23 @@ export async function listCodexAppServerModels(
);
}
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
method: string;
requestParams?: JsonValue;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
}): Promise<T> {
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
(async () => {
const client = await getSharedCodexAppServerClient({ startOptions: params.startOptions });
return await client.request<T>(params.method, params.requestParams);
})(),
timeoutMs,
`codex app-server ${params.method} timed out`,
);
}
export function defaultServerRequestResponse(
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
): JsonValue {
@@ -521,38 +566,6 @@ export function isCodexAppServerApprovalRequest(method: string): boolean {
return method.includes("requestApproval") || method.includes("Approval");
}
function splitShellWords(value: string): string[] {
const words: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (const char of value) {
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
words.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) {
words.push(current);
}
return words;
}
function formatExitValue(value: unknown): string {
if (value === null || value === undefined) {
return "null";
@@ -562,3 +575,86 @@ function formatExitValue(value: unknown): string {
}
return "unknown";
}
function createWebSocketTransport(options: CodexAppServerStartOptions): CodexAppServerTransport {
if (!options.url) {
throw new Error(
"codex app-server websocket transport requires plugins.entries.codex.config.appServer.url",
);
}
const events = new EventEmitter();
const stdout = new PassThrough();
const stderr = new PassThrough();
const headers = {
...options.headers,
...(options.authToken ? { Authorization: `Bearer ${options.authToken}` } : {}),
};
const socket = new WebSocket(options.url, { headers });
const pendingFrames: string[] = [];
let killed = false;
const sendFrame = (frame: string) => {
const trimmed = frame.trim();
if (!trimmed) {
return;
}
if (socket.readyState === WebSocket.OPEN) {
socket.send(trimmed);
return;
}
pendingFrames.push(trimmed);
};
// `initialize` can be written before the WebSocket open event fires. Buffer
// whole JSON-RPC frames so stdio and websocket transports share call timing.
socket.once("open", () => {
for (const frame of pendingFrames.splice(0)) {
socket.send(frame);
}
});
socket.once("error", (error) => events.emit("error", error));
socket.once("close", (code, reason) => {
killed = true;
events.emit("exit", code, reason.toString("utf8"));
});
socket.on("message", (data) => {
const text = websocketFrameToText(data);
stdout.write(text.endsWith("\n") ? text : `${text}\n`);
});
const stdin = new Writable({
write(chunk, _encoding, callback) {
for (const frame of chunk.toString("utf8").split("\n")) {
sendFrame(frame);
}
callback();
},
});
return {
stdin,
stdout,
stderr,
get killed() {
return killed;
},
kill: () => {
killed = true;
socket.close();
},
once: (event, listener) => events.once(event, listener),
};
}
function websocketFrameToText(data: RawData): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString("utf8");
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString("utf8");
}
return Buffer.from(data).toString("utf8");
}

View File

@@ -9,10 +9,13 @@ import {
type CodexAppServerClient,
type CodexServerNotificationHandler,
} from "./client.js";
import { resolveCodexAppServerRuntimeOptions, type CodexAppServerStartOptions } from "./config.js";
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
import { readCodexAppServerBinding } from "./session-binding.js";
type CodexAppServerClientFactory = () => Promise<CodexAppServerClient>;
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
) => Promise<CodexAppServerClient>;
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
turnId?: string;
@@ -26,11 +29,14 @@ type CodexNativeCompactionWaiter = {
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
let clientFactory: CodexAppServerClientFactory = getSharedCodexAppServerClient;
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
getSharedCodexAppServerClient({ startOptions });
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const runtime = resolveEmbeddedAgentRuntime();
const provider = params.provider?.trim().toLowerCase();
const shouldUseCodex =
@@ -48,7 +54,7 @@ export async function maybeCompactCodexAppServerSession(
return undefined;
}
const client = await clientFactory();
const client = await clientFactory(appServer.start);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;
try {
@@ -222,6 +228,6 @@ export const __testing = {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = getSharedCodexAppServerClient;
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
},
} as const;

View File

@@ -0,0 +1,195 @@
export type CodexAppServerTransportMode = "stdio" | "websocket";
export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" | "untrusted";
export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type CodexAppServerApprovalsReviewer = "user" | "guardian_subagent";
export type CodexAppServerStartOptions = {
transport: CodexAppServerTransportMode;
command: string;
args: string[];
url?: string;
authToken?: string;
headers: Record<string, string>;
};
export type CodexAppServerRuntimeOptions = {
start: CodexAppServerStartOptions;
requestTimeoutMs: number;
approvalPolicy: CodexAppServerApprovalPolicy;
sandbox: CodexAppServerSandboxMode;
approvalsReviewer: CodexAppServerApprovalsReviewer;
serviceTier?: string;
};
export type CodexPluginConfig = {
discovery?: {
enabled?: boolean;
timeoutMs?: number;
};
appServer?: {
transport?: CodexAppServerTransportMode;
command?: string;
args?: string[] | string;
url?: string;
authToken?: string;
headers?: Record<string, string>;
requestTimeoutMs?: number;
approvalPolicy?: CodexAppServerApprovalPolicy;
sandbox?: CodexAppServerSandboxMode;
approvalsReviewer?: CodexAppServerApprovalsReviewer;
serviceTier?: string;
};
};
export function readCodexPluginConfig(value: unknown): CodexPluginConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as CodexPluginConfig;
}
export function resolveCodexAppServerRuntimeOptions(
params: {
pluginConfig?: unknown;
env?: NodeJS.ProcessEnv;
} = {},
): CodexAppServerRuntimeOptions {
const env = params.env ?? process.env;
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
const transport = resolveTransport(config.transport);
const command =
readNonEmptyString(config.command) ?? env.OPENCLAW_CODEX_APP_SERVER_BIN ?? "codex";
const args = resolveArgs(config.args, env.OPENCLAW_CODEX_APP_SERVER_ARGS);
const headers = normalizeHeaders(config.headers);
const authToken = readNonEmptyString(config.authToken);
const url = readNonEmptyString(config.url);
return {
start: {
transport,
command,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
},
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
approvalPolicy:
resolveApprovalPolicy(config.approvalPolicy) ??
resolveApprovalPolicy(env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY) ??
"never",
sandbox:
resolveSandbox(config.sandbox) ??
resolveSandbox(env.OPENCLAW_CODEX_APP_SERVER_SANDBOX) ??
"workspace-write",
approvalsReviewer:
resolveApprovalsReviewer(config.approvalsReviewer) ??
(env.OPENCLAW_CODEX_APP_SERVER_GUARDIAN === "1" ? "guardian_subagent" : "user"),
...(readNonEmptyString(config.serviceTier)
? { serviceTier: readNonEmptyString(config.serviceTier) }
: {}),
};
}
export function codexAppServerStartOptionsKey(options: CodexAppServerStartOptions): string {
return JSON.stringify({
transport: options.transport,
command: options.command,
args: options.args,
url: options.url ?? null,
authToken: options.authToken ? "<set>" : null,
headers: Object.entries(options.headers).toSorted(([left], [right]) =>
left.localeCompare(right),
),
});
}
function resolveTransport(value: unknown): CodexAppServerTransportMode {
return value === "websocket" ? "websocket" : "stdio";
}
function resolveApprovalPolicy(value: unknown): CodexAppServerApprovalPolicy | undefined {
return value === "on-request" ||
value === "on-failure" ||
value === "untrusted" ||
value === "never"
? value
: undefined;
}
function resolveSandbox(value: unknown): CodexAppServerSandboxMode | undefined {
return value === "read-only" || value === "workspace-write" || value === "danger-full-access"
? value
: undefined;
}
function resolveApprovalsReviewer(value: unknown): CodexAppServerApprovalsReviewer | undefined {
return value === "guardian_subagent" || value === "user" ? value : undefined;
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function normalizeHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return Object.fromEntries(
Object.entries(value)
.map(([key, child]) => [key.trim(), readNonEmptyString(child)] as const)
.filter((entry): entry is readonly [string, string] => Boolean(entry[0] && entry[1])),
);
}
function resolveArgs(configArgs: unknown, envArgs: string | undefined): string[] {
if (Array.isArray(configArgs)) {
return configArgs
.map((entry) => readNonEmptyString(entry))
.filter((entry): entry is string => entry !== undefined);
}
if (typeof configArgs === "string") {
return splitShellWords(configArgs);
}
return splitShellWords(envArgs ?? "");
}
function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function splitShellWords(value: string): string[] {
const words: string[] = [];
let current = "";
let quote: '"' | "'" | null = null;
for (const char of value) {
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
words.push(current);
current = "";
}
continue;
}
current += char;
}
if (current) {
words.push(current);
}
return words;
}

View File

@@ -55,6 +55,7 @@ export type CodexThreadStartParams = {
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
approvalsReviewer?: "user" | "guardian_subagent";
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
serviceTier?: string | null;
config?: JsonObject | null;
serviceName?: string | null;
baseInstructions?: string | null;
@@ -67,6 +68,13 @@ export type CodexThreadStartParams = {
export type CodexThreadResumeParams = {
threadId: string;
model?: string | null;
modelProvider?: string | null;
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
approvalsReviewer?: "user" | "guardian_subagent";
sandbox?: "read-only" | "workspace-write" | "danger-full-access";
serviceTier?: string | null;
persistExtendedHistory?: boolean;
};
export type CodexThreadStartResponse = {
@@ -84,6 +92,7 @@ export type CodexTurnStartParams = {
approvalPolicy?: "never" | "on-request" | "on-failure" | "untrusted";
approvalsReviewer?: "user" | "guardian_subagent";
model?: string | null;
serviceTier?: string | null;
effort?: "minimal" | "low" | "medium" | "high" | "xhigh" | null;
};

View File

@@ -296,10 +296,99 @@ describe("runCodexAppServerAttempt", () => {
method: "thread/resume",
params: {
threadId: "thread-existing",
model: "gpt-5.4-codex",
modelProvider: "openai",
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: "workspace-write",
persistExtendedHistory: true,
},
},
]),
);
});
it("passes configured app-server policy, sandbox, service tier, and model on resume", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
model: "gpt-5.2",
modelProvider: "openai",
});
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "thread/resume") {
return { thread: { id: "thread-existing" }, modelProvider: "openai" };
}
if (method === "turn/start") {
return { turn: { id: "turn-1", status: "inProgress" } };
}
return {};
});
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
pluginConfig: {
appServer: {
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "danger-full-access",
serviceTier: "priority",
},
},
});
await vi.waitFor(() =>
expect(requests.some((entry) => entry.method === "turn/start")).toBe(true),
);
await notify({
method: "turn/completed",
params: {
threadId: "thread-existing",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
await run;
expect(requests).toEqual(
expect.arrayContaining([
{
method: "thread/resume",
params: {
threadId: "thread-existing",
model: "gpt-5.4-codex",
modelProvider: "openai",
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
sandbox: "danger-full-access",
serviceTier: "priority",
persistExtendedHistory: true,
},
},
{
method: "turn/start",
params: expect.objectContaining({
approvalPolicy: "on-request",
approvalsReviewer: "guardian_subagent",
serviceTier: "priority",
model: "gpt-5.4-codex",
}),
},
]),
);
});
});

View File

@@ -24,6 +24,11 @@ import {
isCodexAppServerApprovalRequest,
type CodexAppServerClient,
} from "./client.js";
import {
resolveCodexAppServerRuntimeOptions,
type CodexAppServerRuntimeOptions,
type CodexAppServerStartOptions,
} from "./config.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { CodexAppServerEventProjector } from "./event-projector.js";
import {
@@ -45,13 +50,18 @@ import {
} from "./session-binding.js";
import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js";
type CodexAppServerClientFactory = () => Promise<CodexAppServerClient>;
type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
) => Promise<CodexAppServerClient>;
let clientFactory: CodexAppServerClientFactory = getSharedCodexAppServerClient;
let clientFactory: CodexAppServerClientFactory = (startOptions) =>
getSharedCodexAppServerClient({ startOptions });
export async function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: { pluginConfig?: unknown } = {},
): Promise<EmbeddedRunAttemptResult> {
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
@@ -106,12 +116,13 @@ export async function runCodexAppServerAttempt(
timeoutMs: params.timeoutMs,
signal: runAbortController.signal,
operation: async () => {
const startupClient = await clientFactory();
const startupClient = await clientFactory(appServer.start);
const startupThread = await startOrResumeThread({
client: startupClient,
params,
cwd: effectiveWorkspace,
dynamicTools: toolBridge.specs,
appServer,
});
return { client: startupClient, thread: startupThread };
},
@@ -178,9 +189,10 @@ export async function runCodexAppServerAttempt(
threadId: thread.threadId,
input: buildUserInput(params),
cwd: effectiveWorkspace,
approvalPolicy: resolveAppServerApprovalPolicy(),
approvalsReviewer: resolveApprovalsReviewer(),
approvalPolicy: appServer.approvalPolicy,
approvalsReviewer: appServer.approvalsReviewer,
model: params.modelId,
...(appServer.serviceTier ? { serviceTier: appServer.serviceTier } : {}),
effort: resolveReasoningEffort(params.thinkLevel),
});
} catch (error) {
@@ -394,11 +406,17 @@ async function startOrResumeThread(params: {
params: EmbeddedRunAttemptParams;
cwd: string;
dynamicTools: JsonValue[];
appServer: CodexAppServerRuntimeOptions;
}): Promise<CodexAppServerThreadBinding> {
const dynamicToolsFingerprint = fingerprintDynamicTools(params.dynamicTools);
const binding = await readCodexAppServerBinding(params.params.sessionFile);
if (binding?.threadId) {
if (binding.dynamicToolsFingerprint !== dynamicToolsFingerprint) {
// `/codex resume <thread>` writes a binding before the next turn can know
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
if (
binding.dynamicToolsFingerprint &&
binding.dynamicToolsFingerprint !== dynamicToolsFingerprint
) {
embeddedAgentLog.debug(
"codex app-server dynamic tool catalog changed; starting a new thread",
{
@@ -410,6 +428,12 @@ async function startOrResumeThread(params: {
try {
const response = await params.client.request<CodexThreadResumeResponse>("thread/resume", {
threadId: binding.threadId,
model: params.params.modelId,
modelProvider: normalizeModelProvider(params.params.provider),
approvalPolicy: params.appServer.approvalPolicy,
approvalsReviewer: params.appServer.approvalsReviewer,
sandbox: params.appServer.sandbox,
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
persistExtendedHistory: true,
});
await writeCodexAppServerBinding(params.params.sessionFile, {
@@ -441,9 +465,10 @@ async function startOrResumeThread(params: {
model: params.params.modelId,
modelProvider: normalizeModelProvider(params.params.provider),
cwd: params.cwd,
approvalPolicy: resolveAppServerApprovalPolicy(),
approvalsReviewer: resolveApprovalsReviewer(),
sandbox: resolveAppServerSandbox(),
approvalPolicy: params.appServer.approvalPolicy,
approvalsReviewer: params.appServer.approvalsReviewer,
sandbox: params.appServer.sandbox,
...(params.appServer.serviceTier ? { serviceTier: params.appServer.serviceTier } : {}),
serviceName: "OpenClaw",
developerInstructions: buildDeveloperInstructions(params.params),
dynamicTools: params.dynamicTools,
@@ -518,26 +543,6 @@ function normalizeModelProvider(provider: string): string {
return provider === "codex" || provider === "openai-codex" ? "openai" : provider;
}
function resolveAppServerApprovalPolicy(): "never" | "on-request" | "on-failure" | "untrusted" {
const raw = process.env.OPENCLAW_CODEX_APP_SERVER_APPROVAL_POLICY?.trim();
if (raw === "on-request" || raw === "on-failure" || raw === "untrusted") {
return raw;
}
return "never";
}
function resolveAppServerSandbox(): "read-only" | "workspace-write" | "danger-full-access" {
const raw = process.env.OPENCLAW_CODEX_APP_SERVER_SANDBOX?.trim();
if (raw === "read-only" || raw === "danger-full-access") {
return raw;
}
return "workspace-write";
}
function resolveApprovalsReviewer(): "user" | "guardian_subagent" {
return process.env.OPENCLAW_CODEX_APP_SERVER_GUARDIAN === "1" ? "guardian_subagent" : "user";
}
function resolveReasoningEffort(
thinkLevel: EmbeddedRunAttemptParams["thinkLevel"],
): "minimal" | "low" | "medium" | "high" | "xhigh" | null {
@@ -633,6 +638,6 @@ export const __testing = {
clientFactory = factory;
},
resetCodexAppServerClientFactoryForTests(): void {
clientFactory = getSharedCodexAppServerClient;
clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions });
},
} as const;

View File

@@ -0,0 +1,84 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetSharedCodexAppServerClientForTests } from "./app-server/client.js";
import { handleCodexCommand } from "./commands.js";
let tempDir: string;
function createContext(args: string, sessionFile?: string): PluginCommandContext {
return {
channel: "test",
isAuthorizedSender: true,
args,
commandBody: `/codex ${args}`,
config: {},
sessionFile,
requestConversationBinding: async () => ({ status: "error", message: "unused" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
};
}
describe("codex command", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-command-"));
});
afterEach(async () => {
resetSharedCodexAppServerClientForTests();
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
it("attaches the current session to an existing Codex thread", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const requests: Array<{ method: string; params: unknown }> = [];
vi.spyOn(
await import("./app-server/client.js"),
"requestCodexAppServerJson",
).mockImplementation(async ({ method, requestParams }) => {
requests.push({ method, params: requestParams });
return {
thread: { id: "thread-123", cwd: "/repo" },
model: "gpt-5.4",
modelProvider: "openai",
};
});
await expect(
handleCodexCommand(createContext("resume thread-123", sessionFile)),
).resolves.toEqual({
text: "Attached this OpenClaw session to Codex thread thread-123.",
});
expect(requests).toEqual([
{
method: "thread/resume",
params: { threadId: "thread-123", persistExtendedHistory: true },
},
]);
await expect(fs.readFile(`${sessionFile}.codex-app-server.json`, "utf8")).resolves.toContain(
'"threadId": "thread-123"',
);
});
it("shows model ids from Codex app-server", async () => {
vi.spyOn(await import("./app-server/client.js"), "listCodexAppServerModels").mockResolvedValue({
models: [
{
id: "gpt-5.4",
model: "gpt-5.4",
inputModalities: ["text"],
supportedReasoningEfforts: ["medium"],
},
],
});
await expect(handleCodexCommand(createContext("models"))).resolves.toEqual({
text: "Codex models:\n- gpt-5.4",
});
});
});

View File

@@ -0,0 +1,299 @@
import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { listCodexAppServerModels, requestCodexAppServerJson } from "./app-server/client.js";
import { resolveCodexAppServerRuntimeOptions } from "./app-server/config.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding,
} from "./app-server/session-binding.js";
export function createCodexCommand(options: {
pluginConfig?: unknown;
}): OpenClawPluginCommandDefinition {
return {
name: "codex",
description: "Inspect and control the Codex app-server harness",
acceptsArgs: true,
requireAuth: true,
handler: (ctx) => handleCodexCommand(ctx, options),
};
}
export async function handleCodexCommand(
ctx: PluginCommandContext,
options: { pluginConfig?: unknown } = {},
): Promise<{ text: string }> {
const [subcommand = "status", ...rest] = splitArgs(ctx.args);
const normalized = subcommand.toLowerCase();
if (normalized === "help") {
return { text: buildHelp() };
}
if (normalized === "status") {
return { text: await buildStatus(options.pluginConfig) };
}
if (normalized === "models") {
return { text: await buildModels(options.pluginConfig) };
}
if (normalized === "threads") {
return { text: await buildThreads(options.pluginConfig, rest.join(" ")) };
}
if (normalized === "resume") {
return { text: await resumeThread(ctx, options.pluginConfig, rest[0]) };
}
if (normalized === "compact") {
return {
text: await startThreadAction(
ctx,
options.pluginConfig,
"thread/compact/start",
"compaction",
),
};
}
if (normalized === "review") {
return { text: await startThreadAction(ctx, options.pluginConfig, "review/start", "review") };
}
if (normalized === "mcp") {
return { text: await buildList(options.pluginConfig, "mcpServerStatus/list", "MCP servers") };
}
if (normalized === "skills") {
return { text: await buildList(options.pluginConfig, "skills/list", "Codex skills") };
}
if (normalized === "account") {
return { text: await buildAccount(options.pluginConfig) };
}
return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` };
}
async function buildStatus(pluginConfig: unknown): Promise<string> {
const [models, account, limits, mcps, skills] = await Promise.all([
safeValue(() => listCodexAppServerModels(requestOptions(pluginConfig, 20))),
safeValue(() => codexRequest(pluginConfig, "account/read", {})),
safeValue(() => codexRequest(pluginConfig, "account/rateLimits/read", {})),
safeValue(() => codexRequest(pluginConfig, "mcpServerStatus/list", { limit: 100 })),
safeValue(() => codexRequest(pluginConfig, "skills/list", {})),
]);
const connected = models.ok || account.ok || limits.ok || mcps.ok || skills.ok;
const lines = [`Codex app-server: ${connected ? "connected" : "unavailable"}`];
if (models.ok) {
lines.push(
`Models: ${
models.value.models
.map((model) => model.id)
.slice(0, 8)
.join(", ") || "none"
}`,
);
} else {
lines.push(`Models: ${models.error}`);
}
lines.push(`Account: ${account.ok ? summarizeAccount(account.value) : account.error}`);
lines.push(`Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`);
lines.push(`MCP servers: ${mcps.ok ? summarizeArrayLike(mcps.value) : mcps.error}`);
lines.push(`Skills: ${skills.ok ? summarizeArrayLike(skills.value) : skills.error}`);
return lines.join("\n");
}
async function buildModels(pluginConfig: unknown): Promise<string> {
const result = await listCodexAppServerModels(requestOptions(pluginConfig, 100));
if (result.models.length === 0) {
return "No Codex app-server models returned.";
}
return [
"Codex models:",
...result.models.map((model) => `- ${model.id}${model.isDefault ? " (default)" : ""}`),
].join("\n");
}
async function buildThreads(pluginConfig: unknown, filter: string): Promise<string> {
const response = await codexRequest(pluginConfig, "thread/list", {
limit: 10,
...(filter.trim() ? { filter: filter.trim() } : {}),
});
const threads = extractArray(response);
if (threads.length === 0) {
return "No Codex threads returned.";
}
return [
"Codex threads:",
...threads.slice(0, 10).map((thread) => {
const record = isJsonObject(thread) ? thread : {};
const id = readString(record, "threadId") ?? readString(record, "id") ?? "<unknown>";
const title =
readString(record, "title") ?? readString(record, "name") ?? readString(record, "summary");
return `- ${id}${title ? ` - ${title}` : ""}`;
}),
].join("\n");
}
async function buildAccount(pluginConfig: unknown): Promise<string> {
const [account, limits] = await Promise.all([
safeValue(() => codexRequest(pluginConfig, "account/read", {})),
safeValue(() => codexRequest(pluginConfig, "account/rateLimits/read", {})),
]);
return [
`Account: ${account.ok ? summarizeAccount(account.value) : account.error}`,
`Rate limits: ${limits.ok ? summarizeArrayLike(limits.value) : limits.error}`,
].join("\n");
}
async function buildList(pluginConfig: unknown, method: string, label: string): Promise<string> {
const response = await codexRequest(pluginConfig, method, { limit: 100 });
const entries = extractArray(response);
if (entries.length === 0) {
return `${label}: none returned.`;
}
return [
`${label}:`,
...entries.slice(0, 25).map((entry) => {
const record = isJsonObject(entry) ? entry : {};
return `- ${readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry)}`;
}),
].join("\n");
}
async function resumeThread(
ctx: PluginCommandContext,
pluginConfig: unknown,
threadId: string | undefined,
): Promise<string> {
const normalizedThreadId = threadId?.trim();
if (!normalizedThreadId) {
return "Usage: /codex resume <thread-id>";
}
if (!ctx.sessionFile) {
return "Cannot attach a Codex thread because this command did not include an OpenClaw session file.";
}
const response = await codexRequest(pluginConfig, "thread/resume", {
threadId: normalizedThreadId,
persistExtendedHistory: true,
});
const thread = isJsonObject(response) && isJsonObject(response.thread) ? response.thread : {};
const effectiveThreadId = readString(thread, "id") ?? normalizedThreadId;
await writeCodexAppServerBinding(ctx.sessionFile, {
threadId: effectiveThreadId,
cwd: readString(thread, "cwd") ?? "",
model: isJsonObject(response) ? readString(response, "model") : undefined,
modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined,
});
return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`;
}
async function startThreadAction(
ctx: PluginCommandContext,
pluginConfig: unknown,
method: string,
label: string,
): Promise<string> {
if (!ctx.sessionFile) {
return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`;
}
const binding = await readCodexAppServerBinding(ctx.sessionFile);
if (!binding?.threadId) {
return `No Codex thread is attached to this OpenClaw session yet.`;
}
await codexRequest(pluginConfig, method, { threadId: binding.threadId });
return `Started Codex ${label} for thread ${binding.threadId}.`;
}
async function codexRequest(
pluginConfig: unknown,
method: string,
requestParams?: JsonValue,
): Promise<JsonValue | undefined> {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
return await requestCodexAppServerJson({
method,
requestParams,
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
});
}
function requestOptions(pluginConfig: unknown, limit: number) {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
return {
limit,
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
};
}
async function safeValue<T>(
read: () => Promise<T>,
): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
try {
return { ok: true, value: await read() };
} catch (error) {
return { ok: false, error: formatError(error) };
}
}
function summarizeAccount(value: JsonValue | undefined): string {
if (!isJsonObject(value)) {
return "unavailable";
}
return (
readString(value, "email") ??
readString(value, "accountEmail") ??
readString(value, "planType") ??
readString(value, "id") ??
"available"
);
}
function summarizeArrayLike(value: JsonValue | undefined): string {
const entries = extractArray(value);
if (entries.length === 0) {
return "none returned";
}
return `${entries.length}`;
}
function extractArray(value: JsonValue | undefined): JsonValue[] {
if (Array.isArray(value)) {
return value;
}
if (!isJsonObject(value)) {
return [];
}
for (const key of ["data", "items", "threads", "models", "skills", "servers", "rateLimits"]) {
const child = value[key];
if (Array.isArray(child)) {
return child;
}
}
return [];
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function splitArgs(value: string | undefined): string[] {
return (value ?? "").trim().split(/\s+/).filter(Boolean);
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildHelp(): string {
return [
"Codex commands:",
"- /codex status",
"- /codex models",
"- /codex threads [filter]",
"- /codex resume <thread-id>",
"- /codex compact",
"- /codex review",
"- /codex account",
"- /codex mcp",
"- /codex skills",
].join("\n");
}

3
pnpm-lock.yaml generated
View File

@@ -423,6 +423,9 @@ importers:
'@mariozechner/pi-coding-agent':
specifier: 0.65.2
version: 0.65.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
ws:
specifier: ^8.20.0
version: 8.20.0
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*

View File

@@ -41,6 +41,7 @@ export const handlePluginCommand: CommandHandler = async (
gatewayClientScopes: params.ctx.GatewayClientScopes,
sessionKey: params.sessionKey,
sessionId: params.sessionEntry?.sessionId,
sessionFile: params.sessionEntry?.sessionFile,
commandBody: command.commandBodyNormalized,
config: cfg,
from: command.from,

View File

@@ -162,6 +162,7 @@ export async function executePluginCommand(params: {
gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"];
sessionKey?: PluginCommandContext["sessionKey"];
sessionId?: PluginCommandContext["sessionId"];
sessionFile?: PluginCommandContext["sessionFile"];
commandBody: string;
config: OpenClawConfig;
from?: PluginCommandContext["from"];
@@ -202,6 +203,7 @@ export async function executePluginCommand(params: {
gatewayClientScopes: params.gatewayClientScopes,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
args: sanitizedArgs,
commandBody,
config,

View File

@@ -1822,6 +1822,8 @@ export type PluginCommandContext = {
sessionKey?: string;
/** Ephemeral host session id for the active conversation when available. */
sessionId?: string;
/** Transcript file for the active OpenClaw session when available. */
sessionFile?: string;
/** Raw command arguments after the command name */
args?: string;
/** The full normalized command body */