Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { COMMANDS } from "./src/commands.js";

const controllerState = vi.hoisted(() => ({
Expand All @@ -22,6 +23,7 @@ vi.mock("./src/controller.js", () => ({
}));

const { default: plugin } = await import("./index.js");
const manifest = JSON.parse(readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"));

describe("plugin registration", () => {
it("loads without the binding resolved hook on older OpenClaw cores", () => {
Expand Down Expand Up @@ -55,4 +57,17 @@ describe("plugin registration", () => {

expect(api.onConversationBindingResolved).toHaveBeenCalledTimes(1);
});

it("declares command activation metadata for every runtime slash command", () => {
const commandNames = COMMANDS.map(([name]) => name);

expect(manifest.activation?.onStartup).toBe(true);
expect(manifest.activation?.onCommands).toEqual(commandNames);
expect(manifest.commandAliases).toEqual(
commandNames.map((name) => ({
name,
kind: "runtime-slash",
})),
);
});
});
120 changes: 120 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,103 @@
"id": "openclaw-codex-app-server",
"name": "OpenClaw Plugin For Codex App Server",
"description": "Independent OpenClaw plugin for the Codex App Server protocol with bound Telegram and Discord conversations.",
"activation": {
"onStartup": true,
"onCommands": [
"cas_resume",
"cas_detach",
"cas_status",
"cas_stop",
"cas_steer",
"cas_plan",
"cas_review",
"cas_compact",
"cas_skills",
"cas_experimental",
"cas_mcp",
"cas_fast",
"cas_model",
"cas_permissions",
"cas_verbose",
"cas_init",
"cas_diff",
"cas_rename"
]
},
"commandAliases": [
{
"name": "cas_resume",
"kind": "runtime-slash"
},
{
"name": "cas_detach",
"kind": "runtime-slash"
},
{
"name": "cas_status",
"kind": "runtime-slash"
},
{
"name": "cas_stop",
"kind": "runtime-slash"
},
{
"name": "cas_steer",
"kind": "runtime-slash"
},
{
"name": "cas_plan",
"kind": "runtime-slash"
},
{
"name": "cas_review",
"kind": "runtime-slash"
},
{
"name": "cas_compact",
"kind": "runtime-slash"
},
{
"name": "cas_skills",
"kind": "runtime-slash"
},
{
"name": "cas_experimental",
"kind": "runtime-slash"
},
{
"name": "cas_mcp",
"kind": "runtime-slash"
},
{
"name": "cas_fast",
"kind": "runtime-slash"
},
{
"name": "cas_model",
"kind": "runtime-slash"
},
{
"name": "cas_permissions",
"kind": "runtime-slash"
},
{
"name": "cas_verbose",
"kind": "runtime-slash"
},
{
"name": "cas_init",
"kind": "runtime-slash"
},
{
"name": "cas_diff",
"kind": "runtime-slash"
},
{
"name": "cas_rename",
"kind": "runtime-slash"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -53,6 +150,17 @@
},
"defaultServiceTier": {
"type": "string"
},
"verbose": {
"type": "boolean"
},
"verboseMaxEvents": {
"type": "number",
"minimum": 1
},
"verboseFlushMs": {
"type": "number",
"minimum": 250
}
}
},
Expand Down Expand Up @@ -100,6 +208,18 @@
"defaultServiceTier": {
"label": "Default Service Tier",
"advanced": true
},
"verbose": {
"label": "Verbose Progress",
"help": "Send short progress updates while Codex is reasoning, using tools, or running commands."
},
"verboseMaxEvents": {
"label": "Verbose Max Events",
"advanced": true
},
"verboseFlushMs": {
"label": "Verbose Flush Delay (ms)",
"advanced": true
}
}
}
9 changes: 9 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ describe("CodexAppServerClient.setThreadModel", () => {
command: "codex",
args: [],
requestTimeoutMs: 1_000,
verbose: false,
verboseMaxEvents: 12,
verboseFlushMs: 2_500,
},
{
debug: vi.fn(),
Expand Down Expand Up @@ -253,6 +256,9 @@ describe("CodexAppServerClient.setThreadPermissions", () => {
command: "codex",
args: [],
requestTimeoutMs: 1_000,
verbose: false,
verboseMaxEvents: 12,
verboseFlushMs: 2_500,
},
{
debug: vi.fn(),
Expand Down Expand Up @@ -320,6 +326,9 @@ describe("CodexAppServerClient.startReview", () => {
command: "codex",
args: [],
requestTimeoutMs: 1_000,
verbose: false,
verboseMaxEvents: 12,
verboseFlushMs: 2_500,
},
{
debug: vi.fn(),
Expand Down
93 changes: 93 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type CompactProgress,
type CompactResult,
type ContextUsageSnapshot,
type CodexProgressEvent,
type CodexTurnInputItem,
type ExperimentalFeatureSummary,
type McpServerSummary,
Expand Down Expand Up @@ -2026,6 +2027,84 @@ function extractAssistantNotificationText(
return { mode: "ignore", text: "" };
}

function extractProgressEventFromItem(params: unknown): CodexProgressEvent | undefined {
const item = asRecord(asRecord(params)?.item) ?? asRecord(params);
if (!item) {
return undefined;
}
const itemId = pickString(item, ["id", "itemId", "item_id"]);
const itemType = pickString(item, ["type"])?.trim();
const normalizedType = itemType?.toLowerCase();
const keyPrefix = itemId || normalizedType || "item";
switch (normalizedType) {
case "reasoning":
return { label: "Reasoning", key: `reasoning:${keyPrefix}` };
case "commandexecution":
return { label: "Command", key: `command:${keyPrefix}` };
case "mcptoolcall": {
return { label: "Tool", key: `mcp:${keyPrefix}` };
}
case "dynamictoolcall": {
return { label: "Tool", key: `dynamic:${keyPrefix}` };
}
case "collabagenttoolcall": {
return { label: "Agent", key: `agent:${keyPrefix}` };
}
case "websearch": {
return { label: "Web search", key: `web:${keyPrefix}` };
}
case "filechange":
return { label: "File edit", key: `file:${keyPrefix}` };
case "imageview":
return { label: "Image view", key: `image-view:${keyPrefix}` };
case "imagegeneration":
return { label: "Image generation", key: `image-generation:${keyPrefix}` };
case "contextcompaction":
return { label: "Compacting context", key: `compact:${keyPrefix}` };
default:
return undefined;
}
}

function extractProgressEventFromNotification(
methodLower: string,
params: unknown,
): CodexProgressEvent | undefined {
if (methodLower === "turn/started") {
return { label: "Working", key: "turn:started" };
}
if (methodLower === "item/started") {
return extractProgressEventFromItem(params);
}
if (
methodLower === "item/reasoning/textdelta" ||
methodLower === "item/reasoning/summarytextdelta" ||
methodLower === "item/reasoning/summarypartadded"
) {
const ids = extractIds(params);
return { label: "Reasoning", key: `reasoning:${ids.itemId ?? ids.runId ?? "delta"}` };
}
if (methodLower === "item/mcptoolcall/progress") {
const ids = extractIds(params);
return {
label: "Tool",
key: `mcp-progress:${ids.itemId ?? ids.runId ?? "progress"}`,
};
}
if (methodLower === "command/exec/outputdelta") {
return { label: "Command output", key: "command-output" };
}
if (methodLower === "item/commandexecution/outputdelta") {
const ids = extractIds(params);
return { label: "Command output", key: `command-output:${ids.itemId ?? ids.runId ?? "item"}` };
}
if (methodLower === "turn/plan/updated" || methodLower === "item/plan/delta") {
const ids = extractIds(params);
return { label: "Planning", key: `planning:${ids.itemId ?? ids.runId ?? "turn"}` };
}
return undefined;
}

function extractPlanDeltaNotification(value: unknown): { itemId?: string; delta: string } {
return {
itemId: extractAssistantItemId(value),
Expand Down Expand Up @@ -3263,6 +3342,7 @@ export class CodexAppServerClient {
collaborationMode?: CollaborationMode;
onPendingInput?: (state: PendingInputState | null) => Promise<void> | void;
onFileEdits?: (text: string) => Promise<void> | void;
onProgress?: (event: CodexProgressEvent) => Promise<void> | void;
onInterrupted?: () => Promise<void> | void;
}): ActiveCodexRun {
let threadId = params.existingThreadId?.trim() || "";
Expand All @@ -3284,6 +3364,18 @@ export class CodexAppServerClient {
let terminalError: TurnTerminalError | undefined;
let approvalCancelled = false;
let notificationQueue = Promise.resolve();
let lastProgressKey = "";
const emitProgress = async (event: CodexProgressEvent | undefined) => {
if (!event || !params.onProgress) {
return;
}
const key = event.key ?? `${event.label}:${event.detail ?? ""}`;
if (key === lastProgressKey) {
return;
}
lastProgressKey = key;
await params.onProgress(event);
};
const pendingInputCoordinator = createPendingInputCoordinator({
onPendingInput: params.onPendingInput,
onActivated: () => {
Expand Down Expand Up @@ -3321,6 +3413,7 @@ export class CodexAppServerClient {
}
threadId ||= ids.threadId ?? "";
turnId ||= ids.runId ?? "";
await emitProgress(extractProgressEventFromNotification(methodLower, notificationParams));
const tokenUsage = extractThreadTokenUsageSnapshot(notificationParams);
if (tokenUsage) {
latestContextUsage = tokenUsage;
Expand Down
1 change: 1 addition & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const COMMANDS = [
["cas_fast", "Toggle or inspect fast mode for the current Codex binding."],
["cas_model", "List or switch the Codex model for the current binding."],
["cas_permissions", "Show Codex permissions and account status."],
["cas_verbose", "Toggle or inspect verbose progress updates."],
["cas_init", "Forward /init to Codex."],
["cas_diff", "Forward /diff to Codex."],
["cas_rename", "Rename the Codex thread and optionally sync the conversation name."],
Expand Down
20 changes: 20 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ function readNumber(
return fallback;
}

function readBoolean(record: Record<string, unknown>, key: string, fallback: boolean): boolean {
const value = record[key];
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (normalized === "true") {
return true;
}
if (normalized === "false") {
return false;
}
}
return fallback;
}

export function resolvePluginSettings(rawConfig: unknown): PluginSettings {
const record = asRecord(rawConfig);
const transport = record.transport === "websocket" ? "websocket" : "stdio";
Expand All @@ -82,6 +99,9 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings {
defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"),
defaultModel: readString(record, "defaultModel"),
defaultServiceTier: readString(record, "defaultServiceTier"),
verbose: readBoolean(record, "verbose", false),
verboseMaxEvents: readNumber(record, "verboseMaxEvents", 12, 1),
verboseFlushMs: readNumber(record, "verboseFlushMs", 2_500, 250),
};
}

Expand Down
Loading