From 2fee1e5d420772d17d5fbffbc7db7116f5eb1d09 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Tue, 7 Apr 2026 06:48:08 +0000 Subject: [PATCH 01/19] Support full-access profile for websocket transport --- src/client.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/client.ts b/src/client.ts index c0bf4ff..72ade43 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2418,6 +2418,11 @@ export function isMissingThreadError(error: unknown): boolean { } function buildFullAccessPluginSettings(settings: PluginSettings): PluginSettings | null { + if (settings.transport === "websocket") { + return { + ...settings, + }; + } if (settings.transport !== "stdio") { return null; } From d597092df95887358f991fdb72c6e3f52d29bc9a Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Sun, 12 Apr 2026 13:02:25 -0400 Subject: [PATCH 02/19] feat: add multi-endpoint CAS support with per-conversation endpoint selection --- README.md | 32 ++ docs/autonomous-worker-tools.md | 141 ++++++ index.test.ts | 3 + index.ts | 12 + openclaw.plugin.json | 55 +++ package.json | 1 + src/agent-tools.ts | 256 ++++++++++ src/client.ts | 22 +- src/commands.ts | 1 + src/config.ts | 86 +++- src/controller.ts | 848 +++++++++++++++++++++++++++++--- src/format.ts | 5 + src/help.ts | 11 + src/state.ts | 70 +++ src/types.ts | 44 +- 15 files changed, 1475 insertions(+), 112 deletions(-) create mode 100644 docs/autonomous-worker-tools.md create mode 100644 src/agent-tools.ts diff --git a/README.md b/README.md index ffc3ae8..9bf0606 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,38 @@ Pre-release packages are published on matching npm dist-tags instead of `latest` 5. Use `/cas_status` to inspect or adjust the binding in place, including model, reasoning, fast mode, permissions, compact, and stop controls. 6. If you leave plan mode through the normal `Implement this plan` button, you do not need `/cas_plan off`; use `/cas_plan off` only when you want to exit planning manually instead. +## Autonomous Worker Tools (experimental) + +This plugin can also expose **agent-callable tools** so OpenClaw can talk to Codex workers **without manual `/cas_*` control**. + +Use this mode when you want OpenClaw to act as an orchestrator over multiple Codex app-server endpoints, for example: + +- `context-worker` as a browser or context worker +- `implementation-worker` as a development worker + +Current tool surface: + +- `codex_workers_describe_endpoints` +- `codex_workers_list_threads` +- `codex_workers_run_task` +- `codex_workers_read_thread_context` + +Notes: + +- These tools talk **directly to Codex app-server endpoints**. They do **not** use an MCP proxy layer. +- `codex_workers_run_task` can create a named thread, continue an existing `threadId`, or reuse a named thread when `reuseThreadByName=true`. +- If Codex requests interactive approval/input during an autonomous run, the tool records the pending input and interrupts the run instead of hanging forever. +- For fully autonomous write actions, you will usually want a worker endpoint that exposes the `full-access` profile. + +Suggested pattern: + +1. `codex_workers_describe_endpoints` +2. `codex_workers_run_task(endpointId="context-worker", ...)` +3. `codex_workers_run_task(endpointId="implementation-worker", threadName="job/...", ...)` +4. `codex_workers_read_thread_context(...)` when you need replay/state + +The manual `/cas_*` commands still remain useful as the human-facing fallback and debugging surface. + ## Command Reference | Command | What it does | Notes / examples | diff --git a/docs/autonomous-worker-tools.md b/docs/autonomous-worker-tools.md new file mode 100644 index 0000000..3404ec3 --- /dev/null +++ b/docs/autonomous-worker-tools.md @@ -0,0 +1,141 @@ +# Autonomous Worker Tools + +This document describes the **agent-callable** tool layer added on top of `openclaw-codex-app-server`. + +## Goal + +Allow OpenClaw to orchestrate one or more Codex workers **directly via Codex app-server**, without requiring a human to drive `/cas_resume`, `/cas_status`, or `/cas_endpoint` manually. + +This is intended for flows like: + +- `context-worker` -> browser or authenticated context worker +- `implementation-worker` -> repo implementation worker +- OpenClaw -> planner / router / memory / reporting layer + +## Why direct app-server instead of MCP here? + +Because the worker relationship is conversational/stateful: + +- persistent threads +- turn execution +- resume / continue +- interrupt +- thread state and replay +- native Codex approvals / pending input semantics + +MCP is still useful **inside** Codex for tools, but for **OpenClaw -> Codex worker control**, app-server is the primary transport. + +## Exposed tools + +### `codex_workers_describe_endpoints` + +Returns: + +- default endpoint +- default workspace/model +- configured endpoints +- whether each endpoint supports `full-access` + +### `codex_workers_list_threads` + +Lists threads on an endpoint. + +Useful before reusing a thread or when trying to resolve a stable worker thread by name. + +Key params: + +- `endpointId` +- `workspaceDir` +- `includeAllWorkspaces` +- `filter` +- `permissionsMode` + +### `codex_workers_run_task` + +Runs a prompt on a Codex worker. + +Supports: + +- starting a fresh turn +- continuing an existing `threadId` +- creating a named thread with `threadName` +- reusing a named thread with `reuseThreadByName=true` +- optional model / reasoning / service tier overrides +- optional collaboration payload +- optional multimodal `input` + +Key params: + +- `endpointId` +- `prompt` +- `workspaceDir` +- `threadId` +- `threadName` +- `reuseThreadByName` +- `permissionsMode` +- `model` +- `reasoningEffort` +- `serviceTier` +- `collaborationMode` +- `input` + +Return shape includes: + +- resolved endpoint/workspace/profile +- resulting `threadId` +- whether a thread was created or reused +- any captured `pendingInput` +- the Codex turn result + +### `codex_workers_read_thread_context` + +Reads: + +- thread state +- thread replay/context summary + +Useful when OpenClaw wants to inspect a worker thread before resuming it. + +## Pending input behavior + +Autonomous tool calls cannot complete an interactive approval loop by themselves. + +So the current behavior is: + +1. detect pending approval/input +2. capture a compact `pendingInput` summary +3. interrupt the run +4. return control to OpenClaw + +This avoids deadlocks. + +## Recommended orchestration pattern + +### Phase 1 — direct autonomous orchestration + +Use these tools directly from OpenClaw: + +1. gather context on `context-worker` +2. pass the structured result to `implementation-worker` +3. continue the same named thread when useful +4. inspect thread context if a run needs to be resumed later + +### Phase 2 — add ClawFlow above it + +ClawFlow is the natural next layer when you want: + +- persistent multi-step jobs +- waiting/resume states +- small persisted outputs +- one owner session around multiple worker turns + +So the intended stack is: + +- **Codex app-server plugin tools first** +- **ClawFlow second** + +## Safety / ops notes + +- Prefer loopback or authenticated websocket endpoints. +- For autonomous write actions, use a dedicated endpoint/profile intentionally configured for that purpose. +- Keep `CAS` as the human fallback/debug surface even after autonomous tools are enabled. diff --git a/index.test.ts b/index.test.ts index 05c5836..07e2773 100644 --- a/index.test.ts +++ b/index.test.ts @@ -27,6 +27,7 @@ describe("plugin registration", () => { it("loads without the binding resolved hook on older OpenClaw cores", () => { const api = { registerService: vi.fn(), + registerTool: vi.fn(), registerInteractiveHandler: vi.fn(), registerCommand: vi.fn(), on: vi.fn(), @@ -34,6 +35,7 @@ describe("plugin registration", () => { expect(() => plugin.register(api as never)).not.toThrow(); expect(api.registerService).toHaveBeenCalledTimes(1); + expect(api.registerTool).toHaveBeenCalledTimes(4); expect(api.on).toHaveBeenCalledWith("inbound_claim", expect.any(Function)); expect(api.registerInteractiveHandler).toHaveBeenCalledTimes(2); expect(api.registerCommand).toHaveBeenCalled(); @@ -45,6 +47,7 @@ describe("plugin registration", () => { it("registers the binding resolved hook when available", () => { const api = { registerService: vi.fn(), + registerTool: vi.fn(), registerInteractiveHandler: vi.fn(), registerCommand: vi.fn(), on: vi.fn(), diff --git a/index.ts b/index.ts index 07f2e28..f6c2ce9 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,5 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { createAgentTools } from "./src/agent-tools.js"; import { CodexPluginController } from "./src/controller.js"; import { COMMANDS } from "./src/commands.js"; import { INTERACTIVE_NAMESPACE } from "./src/types.js"; @@ -11,6 +12,17 @@ const plugin = { api.registerService(controller.createService()); + const toolRegistrar = ( + api as OpenClawPluginApi & { + registerTool?: (tool: unknown) => void; + } + ).registerTool; + if (typeof toolRegistrar === "function") { + for (const tool of createAgentTools(controller)) { + toolRegistrar(tool); + } + } + const bindingResolvedHook = ( api as OpenClawPluginApi & { onConversationBindingResolved?: OpenClawPluginApi["onConversationBindingResolved"]; diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 2c928a0..20bab45 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -41,6 +41,53 @@ "type": "number", "minimum": 100 }, + "defaultEndpoint": { + "type": "string" + }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "transport": { + "type": "string", + "enum": [ + "stdio", + "websocket" + ] + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "url": { + "type": "string" + }, + "authToken": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "requestTimeoutMs": { + "type": "number", + "minimum": 100 + } + } + } + }, "inputTimeoutMs": { "type": "number", "minimum": 1000 @@ -86,6 +133,14 @@ "label": "Request Timeout (ms)", "advanced": true }, + "defaultEndpoint": { + "label": "Default Endpoint", + "advanced": true + }, + "endpoints": { + "label": "Endpoints", + "advanced": true + }, "inputTimeoutMs": { "label": "Input Timeout (ms)", "advanced": true diff --git a/package.json b/package.json index 0cc69ce..438a517 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "openclaw": ">=2026.3.22" }, "dependencies": { + "@sinclair/typebox": "^0.34.41", "ws": "^8.18.3" }, "devDependencies": { diff --git a/src/agent-tools.ts b/src/agent-tools.ts new file mode 100644 index 0000000..f533e75 --- /dev/null +++ b/src/agent-tools.ts @@ -0,0 +1,256 @@ +import { Type } from "@sinclair/typebox"; +import type { CodexPluginController } from "./controller.js"; + +function jsonResult(payload: unknown) { + return { + content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], + structuredContent: payload, + }; +} + +function errorResult(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text", text: `codex_worker_error: ${message}` }], + structuredContent: { + ok: false, + error: { + message, + }, + }, + isError: true, + }; +} + +function readString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function readInputItems(value: unknown): + | Array<{ type: "text"; text: string } | { type: "image"; url: string } | { type: "localImage"; path: string }> + | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const out: Array<{ type: "text"; text: string } | { type: "image"; url: string } | { type: "localImage"; path: string }> = []; + for (const entry of value) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + const type = readString(record.type); + if (type === "text") { + const text = readString(record.text); + if (text) { + out.push({ type, text }); + } + } else if (type === "image") { + const url = readString(record.url); + if (url) { + out.push({ type, url }); + } + } else if (type === "localImage") { + const path = readString(record.path); + if (path) { + out.push({ type, path }); + } + } + } + return out.length > 0 ? out : undefined; +} + +export function createAgentTools(controller: CodexPluginController) { + type ToolCtx = { sessionKey?: string } | undefined; + + return [ + { + name: "codex_workers_describe_endpoints", + description: "Describe the configured Codex app-server worker endpoints available to OpenClaw.", + parameters: Type.Object({}), + async execute() { + try { + return jsonResult({ + ok: true, + ...(await controller.describeAgentEndpoints()), + }); + } catch (error) { + return errorResult(error); + } + }, + }, + { + name: "codex_workers_list_threads", + description: "List Codex threads on a worker endpoint. Use this before reusing an existing thread.", + parameters: Type.Object({ + endpointId: Type.Optional(Type.String({ description: "Configured worker endpoint id, such as `context-worker` or `implementation-worker`." })), + workspaceDir: Type.Optional(Type.String({ description: "Workspace/project directory on the remote worker. Omit to use the endpoint default." })), + includeAllWorkspaces: Type.Optional(Type.Boolean({ description: "When true, do not scope thread discovery to a workspace directory." })), + filter: Type.Optional(Type.String({ description: "Optional search string for thread discovery." })), + permissionsMode: Type.Optional(Type.Union([ + Type.Literal("default"), + Type.Literal("full-access"), + ], { description: "Profile to use for the worker connection." })), + }), + async execute( + _toolCallId: string, + params: unknown, + _signal: AbortSignal, + _onUpdate: unknown, + ctx: ToolCtx, + ) { + try { + const record = (params ?? {}) as Record; + return jsonResult({ + ok: true, + ...(await controller.listAgentThreads({ + sessionKey: ctx?.sessionKey, + endpointId: readString(record.endpointId), + workspaceDir: readString(record.workspaceDir), + includeAllWorkspaces: readBoolean(record.includeAllWorkspaces), + filter: readString(record.filter), + permissionsMode: readString(record.permissionsMode) === "full-access" ? "full-access" : "default", + })), + }); + } catch (error) { + return errorResult(error); + } + }, + }, + { + name: "codex_workers_run_task", + description: "Run a prompt on a Codex worker via app-server, optionally continuing or naming a persistent thread.", + parameters: Type.Object({ + endpointId: Type.Optional(Type.String({ description: "Configured worker endpoint id, such as `context-worker` or `implementation-worker`." })), + prompt: Type.String({ description: "Prompt to send to the remote Codex worker." }), + workspaceDir: Type.Optional(Type.String({ description: "Workspace/project directory on the remote worker. Omit to use the endpoint default." })), + threadId: Type.Optional(Type.String({ description: "Existing Codex thread id to continue." })), + threadName: Type.Optional(Type.String({ description: "Optional stable thread name for new work, e.g. job/JIRA-123/browser-worker." })), + reuseThreadByName: Type.Optional(Type.Boolean({ description: "When true and threadName is set, try to reuse an existing thread with the same title before creating a new one." })), + permissionsMode: Type.Optional(Type.Union([ + Type.Literal("default"), + Type.Literal("full-access"), + ], { description: "Profile to use for the worker connection." })), + model: Type.Optional(Type.String({ description: "Optional model override for the worker thread/turn." })), + reasoningEffort: Type.Optional(Type.String({ description: "Optional reasoning effort override." })), + serviceTier: Type.Optional(Type.String({ description: "Optional Codex service tier override." })), + collaborationMode: Type.Optional(Type.Object({ + mode: Type.String({ description: "Codex collaboration mode." }), + settings: Type.Optional(Type.Object({ + model: Type.Optional(Type.String()), + reasoningEffort: Type.Optional(Type.String()), + developerInstructions: Type.Optional(Type.Union([Type.String(), Type.Null()])), + })), + })), + input: Type.Optional(Type.Array(Type.Object({ + type: Type.Union([ + Type.Literal("text"), + Type.Literal("image"), + Type.Literal("localImage"), + ]), + text: Type.Optional(Type.String()), + url: Type.Optional(Type.String()), + path: Type.Optional(Type.String()), + }), { description: "Optional multimodal input items." })), + }), + async execute( + _toolCallId: string, + params: unknown, + _signal: AbortSignal, + _onUpdate: unknown, + ctx: ToolCtx, + ) { + try { + const record = (params ?? {}) as Record; + const prompt = readString(record.prompt); + if (!prompt) { + throw new Error("prompt is required"); + } + return jsonResult({ + ok: true, + ...(await controller.runAgentTask({ + sessionKey: ctx?.sessionKey, + endpointId: readString(record.endpointId), + prompt, + workspaceDir: readString(record.workspaceDir), + threadId: readString(record.threadId), + threadName: readString(record.threadName), + reuseThreadByName: readBoolean(record.reuseThreadByName), + permissionsMode: readString(record.permissionsMode) === "full-access" ? "full-access" : "default", + model: readString(record.model), + reasoningEffort: readString(record.reasoningEffort), + serviceTier: readString(record.serviceTier), + collaborationMode: + record.collaborationMode && typeof record.collaborationMode === "object" && !Array.isArray(record.collaborationMode) + ? { + mode: readString((record.collaborationMode as Record).mode) || "default", + settings: + (record.collaborationMode as Record).settings && + typeof (record.collaborationMode as Record).settings === "object" && + !Array.isArray((record.collaborationMode as Record).settings) + ? { + model: readString(((record.collaborationMode as Record).settings as Record).model), + reasoningEffort: readString(((record.collaborationMode as Record).settings as Record).reasoningEffort), + developerInstructions: + ((record.collaborationMode as Record).settings as Record).developerInstructions === null + ? null + : readString(((record.collaborationMode as Record).settings as Record).developerInstructions), + } + : undefined, + } + : undefined, + input: readInputItems(record.input), + })), + }); + } catch (error) { + return errorResult(error); + } + }, + }, + { + name: "codex_workers_read_thread_context", + description: "Read the current state and replay summary for a Codex worker thread.", + parameters: Type.Object({ + endpointId: Type.Optional(Type.String({ description: "Configured worker endpoint id, such as `context-worker` or `implementation-worker`." })), + threadId: Type.String({ description: "Codex thread id to inspect." }), + permissionsMode: Type.Optional(Type.Union([ + Type.Literal("default"), + Type.Literal("full-access"), + ], { description: "Profile to use for the worker connection." })), + }), + async execute( + _toolCallId: string, + params: unknown, + _signal: AbortSignal, + _onUpdate: unknown, + ctx: ToolCtx, + ) { + try { + const record = (params ?? {}) as Record; + const threadId = readString(record.threadId); + if (!threadId) { + throw new Error("threadId is required"); + } + return jsonResult({ + ok: true, + ...(await controller.readAgentThreadContext({ + sessionKey: ctx?.sessionKey, + endpointId: readString(record.endpointId), + threadId, + permissionsMode: readString(record.permissionsMode) === "full-access" ? "full-access" : "default", + })), + }); + } catch (error) { + return errorResult(error); + } + }, + }, + ]; +} diff --git a/src/client.ts b/src/client.ts index 72ade43..ae974f7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,9 +18,9 @@ import { type ExperimentalFeatureSummary, type McpServerSummary, type ModelSummary, + type EndpointSettings, type PendingInputAction, type PendingInputState, - type PluginSettings, type PermissionsMode, type RateLimitSummary, type ReviewResult, @@ -83,7 +83,7 @@ const TURN_INTERRUPT_METHODS = ["turn/interrupt"] as const; const execFileAsync = promisify(execFile); type StartupProbeInfo = { - transport: PluginSettings["transport"]; + transport: EndpointSettings["transport"]; command?: string; args?: string[]; resolvedCommandPath?: string; @@ -92,6 +92,8 @@ type StartupProbeInfo = { serverVersion?: string; }; +type ClientEndpointSettings = EndpointSettings & { enabled?: boolean }; + type FileEditSummary = { path: string; verb: "Added" | "Deleted" | "Edited"; @@ -823,7 +825,7 @@ async function dispatchJsonRpcEnvelope( } function createJsonRpcClient( - settings: PluginSettings, + settings: ClientEndpointSettings, logger?: PluginLogger, onClose?: JsonRpcCloseHandler, ): JsonRpcClient { @@ -890,7 +892,7 @@ async function resolveCommandPath(command: string): Promise } } -async function probeStdioVersion(settings: PluginSettings): Promise<{ +async function probeStdioVersion(settings: ClientEndpointSettings): Promise<{ resolvedCommandPath?: string; cliVersion?: string; }> { @@ -1560,7 +1562,7 @@ function extractFileChangePathsFromReadResult( async function readFileChangePathsWithClient(params: { client: JsonRpcClient; - settings: PluginSettings; + settings: EndpointSettings; threadId: string; itemId: string; workspaceDir?: string; @@ -2417,7 +2419,9 @@ export function isMissingThreadError(error: unknown): boolean { ); } -function buildFullAccessPluginSettings(settings: PluginSettings): PluginSettings | null { +function buildFullAccessPluginSettings( + settings: ClientEndpointSettings, +): ClientEndpointSettings | null { if (settings.transport === "websocket") { return { ...settings, @@ -2450,7 +2454,7 @@ export class CodexAppServerClient { private readonly requestListeners = new Set(); constructor( - private readonly settings: PluginSettings, + private readonly settings: ClientEndpointSettings, private readonly logger: PluginLogger, ) {} @@ -2543,7 +2547,7 @@ export class CodexAppServerClient { params: { sessionKey?: string }, callback: (args: { client: JsonRpcClient; - settings: PluginSettings; + settings: EndpointSettings; initializeResult: unknown; }) => Promise, ): Promise { @@ -3773,7 +3777,7 @@ export class CodexAppServerModeClient { private readonly clients: Record; constructor( - settings: PluginSettings, + settings: ClientEndpointSettings, logger: PluginLogger, ) { const fullAccessSettings = buildFullAccessPluginSettings(settings); diff --git a/src/commands.ts b/src/commands.ts index 3b21b06..d341993 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -12,6 +12,7 @@ export const COMMANDS = [ ["cas_mcp", "List Codex MCP servers."], ["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_endpoint", "Show or switch the active Codex endpoint for this conversation."], ["cas_permissions", "Show Codex permissions and account status."], ["cas_init", "Forward /init to Codex."], ["cas_diff", "Forward /diff to Codex."], diff --git a/src/config.ts b/src/config.ts index 5d1ab5f..afd33c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import type { PluginSettings } from "./types.js"; +import type { EndpointSettings, PluginSettings } from "./types.js"; import { DEFAULT_REQUEST_TIMEOUT_MS, } from "./types.js"; @@ -43,6 +43,14 @@ function readHeaders(record: Record): Record | return Object.keys(headers).length > 0 ? headers : undefined; } +function normalizeEndpointId(value: string | undefined, fallback: string): string { + const trimmed = value?.trim(); + if (!trimmed) { + return fallback; + } + return trimmed.replace(/\s+/g, "-"); +} + function readNumber( record: Record, key: string, @@ -58,27 +66,69 @@ function readNumber( export function resolvePluginSettings(rawConfig: unknown): PluginSettings { const record = asRecord(rawConfig); - const transport = record.transport === "websocket" ? "websocket" : "stdio"; - const authToken = readString(record, "authToken"); - const configuredHeaders = readHeaders(record); - const headers = { - ...(configuredHeaders ?? {}), - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + const endpointRecords = Array.isArray(record.endpoints) + ? record.endpoints + .map((entry) => asRecord(entry)) + .filter((entry): entry is Record => Boolean(entry)) + : []; + + const parseEndpoint = (entry: Record, index: number): EndpointSettings => { + const transport = entry.transport === "websocket" ? "websocket" : "stdio"; + const authToken = readString(entry, "authToken"); + const configuredHeaders = readHeaders(entry); + const headers = { + ...(configuredHeaders ?? {}), + ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }; + const fallbackId = index === 0 ? "default" : `endpoint-${index + 1}`; + return { + id: normalizeEndpointId(readString(entry, "id"), fallbackId), + transport, + command: readString(entry, "command") ?? "codex", + args: readStringArray(entry, "args"), + url: readString(entry, "url"), + headers: Object.keys(headers).length > 0 ? headers : undefined, + requestTimeoutMs: readNumber(entry, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS, 100), + }; }; + const legacyTransport: EndpointSettings["transport"] = + record.transport === "websocket" ? "websocket" : "stdio"; + const legacyAuthToken = readString(record, "authToken"); + const legacyConfiguredHeaders = readHeaders(record); + const legacyHeaders = { + ...(legacyConfiguredHeaders ?? {}), + ...(legacyAuthToken ? { Authorization: `Bearer ${legacyAuthToken}` } : {}), + }; + + const endpoints = + endpointRecords.length > 0 + ? endpointRecords.map(parseEndpoint) + : [ + { + id: "default", + transport: legacyTransport, + command: readString(record, "command") ?? "codex", + args: readStringArray(record, "args"), + url: readString(record, "url"), + headers: Object.keys(legacyHeaders).length > 0 ? legacyHeaders : undefined, + requestTimeoutMs: readNumber( + record, + "requestTimeoutMs", + DEFAULT_REQUEST_TIMEOUT_MS, + 100, + ), + }, + ]; + + const requestedDefaultEndpoint = readString(record, "defaultEndpoint"); + const defaultEndpoint = + endpoints.find((entry) => entry.id === requestedDefaultEndpoint)?.id ?? endpoints[0]?.id ?? "default"; + return { enabled: record.enabled !== false, - transport, - command: readString(record, "command") ?? "codex", - args: readStringArray(record, "args"), - url: readString(record, "url"), - headers: Object.keys(headers).length > 0 ? headers : undefined, - requestTimeoutMs: readNumber( - record, - "requestTimeoutMs", - DEFAULT_REQUEST_TIMEOUT_MS, - 100, - ), + defaultEndpoint, + endpoints, defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"), defaultModel: readString(record, "defaultModel"), defaultServiceTier: readString(record, "defaultServiceTier"), diff --git a/src/controller.ts b/src/controller.ts index 4ba6fb1..c75ecf8 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -56,8 +56,10 @@ import type { CodexTurnInputItem, ConversationPreferences, InteractiveMessageRef, + PendingInputState, PermissionsMode, ThreadState, + TurnResult, TurnTerminalError, } from "./types.js"; import { @@ -89,7 +91,6 @@ import { PLUGIN_ID, type CallbackAction, type ConversationTarget, - type PendingInputState, type StoredBinding, type StoredPendingBind, type StoredPendingRequest, @@ -1210,6 +1211,21 @@ function hasCommandPreferenceOverrides(overrides: CommandPreferenceOverrides): b ); } +function parseEndpointArgs(args: string): { endpointId?: string; error?: string } { + const trimmed = args.trim(); + if (!trimmed) { + return {}; + } + const tokens = normalizeOptionDashes(trimmed) + .split(/\s+/) + .map((token) => token.trim()) + .filter(Boolean); + if (tokens.length !== 1) { + return { error: formatCommandUsage("cas_endpoint") }; + } + return { endpointId: tokens[0] }; +} + function mergeConversationPreferences( existing: ConversationPreferences | undefined, updates: Partial, @@ -1360,7 +1376,7 @@ function summarizeTextForLog(text: string, maxChars = 120): string { export class CodexPluginController { private readonly settings; - private readonly client; + private readonly clients = new Map(); private readonly activeRuns = new Map(); private readonly threadChangesCache = new Map>(); private readonly store; @@ -1370,7 +1386,6 @@ export class CodexPluginController { constructor(private readonly api: OpenClawPluginApi) { this.settings = resolvePluginSettings(this.api.pluginConfig); - this.client = new CodexAppServerModeClient(this.settings, this.api.logger); this.store = new PluginStateStore(this.api.runtime.state.resolveStateDir()); } @@ -1392,7 +1407,9 @@ export class CodexPluginController { return; } await this.store.load(); - await this.client.logStartupProbe().catch(() => undefined); + for (const endpoint of this.settings.endpoints) { + await this.getClientForEndpoint(endpoint.id).logStartupProbe().catch(() => undefined); + } this.started = true; } @@ -1404,10 +1421,380 @@ export class CodexPluginController { await active.handle.interrupt().catch(() => undefined); } this.activeRuns.clear(); - await this.client.close().catch(() => undefined); + for (const client of this.clients.values()) { + await client.close().catch(() => undefined); + } + this.clients.clear(); this.started = false; } + async describeAgentEndpoints(): Promise<{ + defaultEndpoint: string; + defaultWorkspaceDir: string | null; + defaultModel: string | null; + endpoints: Array<{ + id: string; + transport: string; + url: string | null; + command: string; + args: string[]; + requestTimeoutMs: number; + supportsFullAccess: boolean; + }>; + }> { + await this.start(); + return { + defaultEndpoint: this.settings.defaultEndpoint, + defaultWorkspaceDir: this.settings.defaultWorkspaceDir ?? null, + defaultModel: this.settings.defaultModel ?? null, + endpoints: this.settings.endpoints.map((endpoint, index) => ({ + id: endpoint.id ?? `endpoint-${index + 1}`, + transport: endpoint.transport, + url: endpoint.url ?? null, + command: endpoint.command, + args: [...endpoint.args], + requestTimeoutMs: endpoint.requestTimeoutMs, + supportsFullAccess: this.getClientForEndpoint(endpoint.id).hasProfile("full-access"), + })), + }; + } + + async listAgentThreads(params: { + sessionKey?: string; + endpointId?: string; + workspaceDir?: string; + includeAllWorkspaces?: boolean; + filter?: string; + permissionsMode?: PermissionsMode; + }): Promise<{ + endpointId: string; + workspaceDir: string | null; + filter: string | null; + permissionsMode: PermissionsMode; + threads: Awaited>; + }> { + await this.start(); + const endpointId = this.resolveAgentEndpointId(params.endpointId); + const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); + const workspaceDir = params.includeAllWorkspaces + ? undefined + : resolveWorkspaceDir({ + requested: params.workspaceDir, + configuredWorkspaceDir: this.settings.defaultWorkspaceDir, + serviceWorkspaceDir: this.serviceWorkspaceDir, + }); + const threads = await this.getClientForEndpoint(endpointId).listThreads({ + sessionKey: params.sessionKey, + workspaceDir, + filter: params.filter?.trim() || undefined, + profile: permissionsMode, + }); + return { + endpointId, + workspaceDir: workspaceDir ?? null, + filter: params.filter?.trim() || null, + permissionsMode, + threads, + }; + } + + async readAgentThreadContext(params: { + sessionKey?: string; + endpointId?: string; + threadId: string; + permissionsMode?: PermissionsMode; + }): Promise<{ + endpointId: string; + permissionsMode: PermissionsMode; + threadId: string; + state: ThreadState; + context: Awaited>; + }> { + await this.start(); + const endpointId = this.resolveAgentEndpointId(params.endpointId); + const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); + const threadId = params.threadId.trim(); + const client = this.getClientForEndpoint(endpointId); + const [state, context] = await Promise.all([ + client.readThreadState({ + sessionKey: params.sessionKey, + threadId, + profile: permissionsMode, + }), + client.readThreadContext({ + sessionKey: params.sessionKey, + threadId, + profile: permissionsMode, + }), + ]); + return { + endpointId, + permissionsMode, + threadId, + state, + context, + }; + } + + async runAgentTask(params: { + sessionKey?: string; + endpointId?: string; + prompt: string; + workspaceDir?: string; + threadId?: string; + threadName?: string; + reuseThreadByName?: boolean; + permissionsMode?: PermissionsMode; + model?: string; + reasoningEffort?: string; + serviceTier?: string; + collaborationMode?: CollaborationMode; + input?: readonly CodexTurnInputItem[]; + }): Promise<{ + endpointId: string; + workspaceDir: string; + permissionsMode: PermissionsMode; + threadId: string; + threadName: string | null; + reusedThreadByName: boolean; + createdThread: boolean; + pendingInput: null | Pick; + result: TurnResult; + }> { + await this.start(); + const endpointId = this.resolveAgentEndpointId(params.endpointId); + const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); + const workspaceDir = resolveWorkspaceDir({ + requested: params.workspaceDir, + configuredWorkspaceDir: this.settings.defaultWorkspaceDir, + serviceWorkspaceDir: this.serviceWorkspaceDir, + }); + const threadName = params.threadName?.trim() || ""; + const client = this.getClientForEndpoint(endpointId); + let threadId = params.threadId?.trim() || ""; + let reusedThreadByName = false; + let createdThread = false; + + if (!threadId && params.reuseThreadByName && threadName) { + const matches = await client.listThreads({ + sessionKey: params.sessionKey, + workspaceDir, + filter: threadName, + profile: permissionsMode, + }); + const exactMatch = + matches.find((entry) => entry.title?.trim() === threadName) ?? + matches.find((entry) => entry.threadId.trim() === threadName); + if (exactMatch) { + threadId = exactMatch.threadId; + reusedThreadByName = true; + } + } + + if (!threadId && threadName) { + const created = await client.startThread({ + sessionKey: params.sessionKey, + workspaceDir, + model: params.model?.trim() || this.settings.defaultModel, + profile: permissionsMode, + }); + threadId = created.threadId; + createdThread = true; + await client.setThreadName({ + sessionKey: params.sessionKey, + threadId, + name: threadName, + profile: permissionsMode, + }); + if (params.serviceTier?.trim()) { + await client.setThreadServiceTier({ + sessionKey: params.sessionKey, + threadId, + serviceTier: params.serviceTier.trim(), + profile: permissionsMode, + }); + } + } + + let pendingInput: null | Pick = null; + let activeRun: ActiveCodexRun | null = null; + activeRun = client.startTurn({ + sessionKey: params.sessionKey, + prompt: params.prompt, + input: params.input, + workspaceDir, + runId: `agent-${crypto.randomUUID()}`, + existingThreadId: threadId || undefined, + model: params.model?.trim() || this.settings.defaultModel, + reasoningEffort: params.reasoningEffort?.trim() || undefined, + serviceTier: params.serviceTier?.trim() || this.settings.defaultServiceTier, + collaborationMode: params.collaborationMode, + onPendingInput: async (state) => { + pendingInput = state + ? { + requestId: state.requestId, + options: [...state.options], + promptText: state.promptText, + method: state.method, + } + : null; + if (state) { + await activeRun?.interrupt().catch(() => undefined); + } + }, + }); + + const rawResult = await activeRun.result; + if (!("threadId" in rawResult)) { + throw new Error("Codex startTurn returned a non-turn result."); + } + const result: TurnResult = rawResult; + return { + endpointId, + workspaceDir, + permissionsMode, + threadId: result.threadId, + threadName: threadName || null, + reusedThreadByName, + createdThread, + pendingInput, + result, + }; + } + + private resolveAgentEndpointId(endpointId?: string): string { + const requested = endpointId?.trim(); + if (!requested) { + return this.settings.defaultEndpoint; + } + if (!this.settings.endpoints.some((entry) => entry.id === requested)) { + throw new Error(`Unknown Codex endpoint: ${requested}`); + } + return requested; + } + + private resolveAgentPermissionsMode( + endpointId: string, + requested?: PermissionsMode, + ): PermissionsMode { + const resolved = requested === "full-access" ? "full-access" : "default"; + if (resolved === "full-access" && !this.getClientForEndpoint(endpointId).hasProfile("full-access")) { + throw new Error(`Codex endpoint ${endpointId} does not expose the full-access profile.`); + } + return resolved; + } + + private getEndpointIdForBinding(binding: StoredBinding | StoredPendingBind | null | undefined): string { + const requested = binding?.endpointId?.trim(); + if (requested && this.settings.endpoints.some((entry) => entry.id === requested)) { + return requested; + } + return this.settings.defaultEndpoint; + } + + private getSelectedEndpointId( + conversation: ConversationTarget | null | undefined, + binding?: StoredBinding | StoredPendingBind | null, + ): string { + if (conversation) { + const stored = this.store.getConversationEndpoint(conversation)?.endpointId?.trim(); + if (stored && this.settings.endpoints.some((entry) => entry.id === stored)) { + return stored; + } + } + return this.getEndpointIdForBinding(binding); + } + + private async setSelectedEndpointId(conversation: ConversationTarget, endpointId: string): Promise { + await this.store.upsertConversationEndpoint({ + conversation: { + channel: conversation.channel, + accountId: conversation.accountId, + conversationId: conversation.conversationId, + parentConversationId: conversation.parentConversationId, + }, + endpointId, + updatedAt: Date.now(), + }); + } + + private formatEndpointListText(params: { + selectedEndpointId: string; + binding?: StoredBinding | null; + }): string { + const lines = [ + `Selected endpoint: ${params.selectedEndpointId}`, + params.binding + ? `Bound endpoint: ${this.getEndpointIdForBinding(params.binding)}` + : "Bound endpoint: none", + "", + "Configured endpoints:", + ...this.settings.endpoints.map((endpoint) => { + const markers = [ + endpoint.id === params.selectedEndpointId ? "selected" : "", + params.binding && endpoint.id === this.getEndpointIdForBinding(params.binding) ? "bound" : "", + endpoint.id === this.settings.defaultEndpoint ? "default" : "", + ].filter(Boolean); + return `- ${endpoint.id} (${endpoint.transport})${markers.length ? ` [${markers.join(", ")}]` : ""}`; + }), + ]; + if ( + params.binding && + this.getEndpointIdForBinding(params.binding) !== params.selectedEndpointId + ) { + lines.push( + "", + "Note: this conversation is still bound to a thread on a different endpoint. Use /cas_resume after detaching if you want to bind on the selected endpoint.", + ); + } + return lines.join("\n"); + } + + private buildEndpointSelectionNotice( + endpointId: string, + binding?: StoredBinding | null, + ): string { + return [ + `Selected endpoint set to ${endpointId}.`, + binding && this.getEndpointIdForBinding(binding) !== endpointId + ? `This conversation is still bound to a thread on ${this.getEndpointIdForBinding(binding)}. Use /cas_resume to browse/bind on ${endpointId}.` + : "", + "", + this.formatEndpointListText({ + selectedEndpointId: endpointId, + binding, + }), + ].filter(Boolean).join("\n"); + } + + private getClientForEndpoint(endpointId?: string): CodexAppServerModeClient { + const resolvedEndpointId = + endpointId && this.settings.endpoints.some((entry) => entry.id === endpointId) + ? endpointId + : this.settings.defaultEndpoint; + const existing = this.clients.get(resolvedEndpointId); + if (existing) { + return existing; + } + const endpoint = + this.settings.endpoints.find((entry) => entry.id === resolvedEndpointId) ?? + this.settings.endpoints[0]; + if (!endpoint) { + throw new Error("Codex endpoint configuration is missing."); + } + const client = new CodexAppServerModeClient(endpoint, this.api.logger); + this.clients.set(resolvedEndpointId, client); + return client; + } + + private getClientForBinding(binding: StoredBinding | StoredPendingBind | null | undefined): CodexAppServerModeClient { + return this.getClientForEndpoint(this.getEndpointIdForBinding(binding)); + } + + private get client(): CodexAppServerModeClient { + return this.getClientForEndpoint(); + } + async handleConversationBindingResolved( event: PluginConversationBindingResolvedEvent, ): Promise { @@ -1441,6 +1828,7 @@ export class CodexPluginController { } await this.bindConversation(conversation, { threadId: pending.threadId, + endpointId: pending.endpointId, workspaceDir: pending.workspaceDir, threadTitle: pending.threadTitle, permissionsMode: normalizePermissionsMode(pending.permissionsMode), @@ -1943,6 +2331,8 @@ export class CodexPluginController { return await this.handleFastCommand(binding, args); case "cas_model": return await this.handleModelCommand(conversation, binding, args); + case "cas_endpoint": + return await this.handleEndpointCommand(conversation, binding, args); case "cas_permissions": return await this.handlePermissionsCommand( conversation, @@ -1967,6 +2357,7 @@ export class CodexPluginController { private async handleStartNewThreadSelection( conversation: ConversationTarget | null, binding: StoredBinding | null, + endpointId: string | undefined, parsed: ReturnType, channel: string, requestConversationBinding?: PickerResponders["requestConversationBinding"], @@ -1975,7 +2366,7 @@ export class CodexPluginController { return { text: "This command needs a Telegram or Discord conversation." }; } if (parsed.listProjects || !parsed.query) { - const picker = await this.renderProjectPicker(conversation, binding, parsed, 0, "start-new-thread"); + const picker = await this.renderProjectPicker(conversation, binding, parsed, 0, "start-new-thread", endpointId); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -1990,7 +2381,7 @@ export class CodexPluginController { const workspaceDir = await this.resolveNewThreadWorkspaceDir(binding, parsed); if (!workspaceDir) { - const picker = await this.renderProjectPicker(conversation, binding, parsed, 0, "start-new-thread"); + const picker = await this.renderProjectPicker(conversation, binding, parsed, 0, "start-new-thread", endpointId); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -2008,6 +2399,7 @@ export class CodexPluginController { const result = await this.startNewThreadAndBindConversation( conversation, binding, + endpointId, workspaceDir, parsed.syncTopic, { @@ -2029,6 +2421,7 @@ export class CodexPluginController { private async handleListCommand( conversation: ConversationTarget | null, binding: StoredBinding | null, + endpointId: string | undefined, filter: string, channel: string, ): Promise { @@ -2037,8 +2430,8 @@ export class CodexPluginController { return { text: "This command needs a Telegram or Discord conversation." }; } const picker = parsed.listProjects - ? await this.renderProjectPicker(conversation, binding, parsed, 0) - : await this.renderThreadPicker(conversation, binding, parsed, 0); + ? await this.renderProjectPicker(conversation, binding, parsed, 0, "resume-thread", endpointId) + : await this.renderThreadPicker(conversation, binding, parsed, 0, undefined, endpointId); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -2068,7 +2461,16 @@ export class CodexPluginController { if (parsed.error) { return { text: parsed.error }; } - if (parsed.requestedYolo && !this.hasFullAccessProfile()) { + const selectedEndpointId = this.getSelectedEndpointId(conversation, binding); + const resumeBinding = + binding && this.getEndpointIdForBinding(binding) === selectedEndpointId ? binding : null; + const resumePendingBind = + pendingBind && this.getEndpointIdForBinding(pendingBind) === selectedEndpointId ? pendingBind : null; + const resumeHydratedPendingBind = + hydratedPendingBind && this.getEndpointIdForBinding(hydratedPendingBind) === selectedEndpointId + ? hydratedPendingBind + : undefined; + if (parsed.requestedYolo && !this.hasFullAccessProfile(selectedEndpointId)) { return { text: "Full Access is unavailable in the current Codex Desktop session." }; } if (parsed.requestedFast && parsed.requestedModel && !modelSupportsFast(parsed.requestedModel)) { @@ -2084,22 +2486,23 @@ export class CodexPluginController { if (parsed.startNew) { return await this.handleStartNewThreadSelection( conversation, - binding, + resumeBinding, + selectedEndpointId, parsed, channel, bindingApi.requestConversationBinding, ); } if ( - hydratedPendingBind?.notifyBound && + resumeHydratedPendingBind?.notifyBound && !parsed.listProjects && !parsed.query ) { - if (hydratedPendingBind.syncTopic) { + if (resumeHydratedPendingBind.syncTopic) { const syncedName = buildResumeTopicName({ - title: hydratedPendingBind.threadTitle, - projectKey: hydratedPendingBind.workspaceDir, - threadId: hydratedPendingBind.threadId, + title: resumeHydratedPendingBind.threadTitle, + projectKey: resumeHydratedPendingBind.workspaceDir, + threadId: resumeHydratedPendingBind.threadId, }); if (syncedName) { await this.renameConversationIfSupported(conversation, syncedName); @@ -2108,24 +2511,25 @@ export class CodexPluginController { await this.sendBoundConversationNotifications(conversation); return {}; } - if (pendingBind && !binding && !parsed.listProjects && !parsed.query) { - const syncTopic = parsed.syncTopic || Boolean(pendingBind.syncTopic); + if (resumePendingBind && !resumeBinding && !parsed.listProjects && !parsed.query) { + const syncTopic = parsed.syncTopic || Boolean(resumePendingBind.syncTopic); const targetPermissionsMode = this.resolveRequestedPermissionsMode( - normalizePermissionsMode(pendingBind.permissionsMode), + normalizePermissionsMode(resumePendingBind.permissionsMode), parsed.requestedYolo, ); const preferences = this.buildBindingPreferencesWithOverrides( - pendingBind.preferences, + resumePendingBind.preferences, overrides, parsed.requestedModel, ); const bindResult = await this.requestConversationBinding( conversation, { - threadId: pendingBind.threadId, - workspaceDir: pendingBind.workspaceDir, + threadId: resumePendingBind.threadId, + endpointId: resumePendingBind.endpointId, + workspaceDir: resumePendingBind.workspaceDir, permissionsMode: targetPermissionsMode, - threadTitle: pendingBind.threadTitle, + threadTitle: resumePendingBind.threadTitle, syncTopic, preferences, notifyBound: true, @@ -2140,9 +2544,9 @@ export class CodexPluginController { } if (syncTopic) { const syncedName = buildResumeTopicName({ - title: pendingBind.threadTitle, - projectKey: pendingBind.workspaceDir, - threadId: pendingBind.threadId, + title: resumePendingBind.threadTitle, + projectKey: resumePendingBind.workspaceDir, + threadId: resumePendingBind.threadId, }); if (syncedName) { await this.renameConversationIfSupported(conversation, syncedName); @@ -2153,11 +2557,18 @@ export class CodexPluginController { } if (parsed.listProjects || !parsed.query) { const passthroughArgs = formatThreadSelectionFlags(parsed); - return await this.handleListCommand(conversation, binding, passthroughArgs, channel); + return await this.handleListCommand( + conversation, + resumeBinding, + selectedEndpointId, + passthroughArgs, + channel, + ); } - const workspaceDir = this.resolveThreadWorkspaceDir(parsed, binding, false); + const workspaceDir = this.resolveThreadWorkspaceDir(parsed, resumeBinding, false); const selection = await this.resolveSingleThread( - binding?.sessionKey, + selectedEndpointId, + resumeBinding?.sessionKey, workspaceDir, parsed.query, ); @@ -2165,7 +2576,14 @@ export class CodexPluginController { return { text: `No Codex thread matched "${parsed.query}".` }; } if (selection.kind === "ambiguous") { - const picker = await this.renderThreadPicker(conversation, binding, parsed, 0); + const picker = await this.renderThreadPicker( + conversation, + resumeBinding, + parsed, + 0, + undefined, + selectedEndpointId, + ); if (isDiscordChannel(channel) && picker.buttons) { try { await this.sendDiscordPicker(conversation, picker); @@ -2180,21 +2598,22 @@ export class CodexPluginController { return buildReplyWithButtons(picker.text, picker.buttons); } const targetPermissionsMode = this.resolveRequestedPermissionsMode( - this.getPermissionsMode(binding), + this.getPermissionsMode(resumeBinding), parsed.requestedYolo, ); const preferences = this.buildBindingPreferencesWithOverrides( - binding?.preferences, + resumeBinding?.preferences, overrides, parsed.requestedModel, ); const bindResult = await this.requestConversationBinding(conversation, { threadId: selection.thread.threadId, + endpointId: selectedEndpointId, workspaceDir: selection.thread.projectKey || workspaceDir || resolveWorkspaceDir({ - bindingWorkspaceDir: binding?.workspaceDir, + bindingWorkspaceDir: resumeBinding?.workspaceDir, configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }), @@ -2258,7 +2677,7 @@ export class CodexPluginController { currentPermissionsMode, parsed.requestedYolo, ); - if (targetPermissionsMode === "full-access" && !this.hasFullAccessProfile()) { + if (targetPermissionsMode === "full-access" && !this.hasFullAccessProfile(binding)) { note = buildPermissionsUnavailableNote(); const card = await this.buildStatusCard(conversation, binding, bindingActive); const text = `${card.text}\n\n${note}`; @@ -2308,8 +2727,13 @@ export class CodexPluginController { return await this.sendStatusCardCommandReply(conversation, text, card.buttons); } - private hasFullAccessProfile(): boolean { - return this.client.hasProfile("full-access"); + private hasFullAccessProfile( + bindingOrEndpoint?: StoredBinding | StoredPendingBind | string | null, + ): boolean { + if (typeof bindingOrEndpoint === "string") { + return this.getClientForEndpoint(bindingOrEndpoint).hasProfile("full-access"); + } + return this.getClientForBinding(bindingOrEndpoint).hasProfile("full-access"); } private getPermissionsMode(binding: StoredBinding | null | undefined): PermissionsMode { @@ -2336,7 +2760,8 @@ export class CodexPluginController { effectiveState: ThreadState | undefined; }> { const profile = this.getPermissionsMode(binding); - const state = await this.client.readThreadState({ + const client = this.getClientForBinding(binding); + const state = await client.readThreadState({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -2359,7 +2784,7 @@ export class CodexPluginController { } const configuredDefault = this.settings.defaultModel?.trim() || undefined; try { - const models = await this.client.listModels({ + const models = await this.getClientForBinding(binding).listModels({ profile: this.getPermissionsMode(binding), sessionKey: binding.sessionKey, }); @@ -2413,9 +2838,10 @@ export class CodexPluginController { }, ): Promise { const profile = this.getPermissionsMode(binding); + const client = this.getClientForBinding(binding); let state = opts?.threadState ?? - (await this.client.readThreadState({ + (await client.readThreadState({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -2423,7 +2849,7 @@ export class CodexPluginController { let desired = buildDesiredThreadConfiguration(state, binding, opts?.modelFallback); if (desired.model && desired.model !== state?.model?.trim()) { try { - state = await this.client.setThreadModel({ + state = await client.setThreadModel({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -2440,7 +2866,7 @@ export class CodexPluginController { const desiredServiceTier = normalizePreferenceServiceTier(desired.effectiveState?.serviceTier); if (desiredServiceTier !== currentServiceTier) { try { - state = await this.client.setThreadServiceTier({ + state = await client.setThreadServiceTier({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -2463,7 +2889,7 @@ export class CodexPluginController { ) ) { try { - state = await this.client.setThreadPermissions({ + state = await client.setThreadPermissions({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -2488,11 +2914,15 @@ export class CodexPluginController { const currentReasoning = normalizeReasoningEffort( effectiveState?.reasoningEffort ?? binding.preferences?.preferredReasoningEffort, ); - const [showModelPicker, showReasoningPicker, togglePermissions, compactThread, stopRun, refreshStatus, detachThread, showSkills, showMcp] = await Promise.all([ + const [showModelPicker, showEndpointPicker, showReasoningPicker, togglePermissions, compactThread, stopRun, refreshStatus, detachThread, showSkills, showMcp] = await Promise.all([ this.store.putCallback({ kind: "show-model-picker", conversation, }), + this.store.putCallback({ + kind: "show-endpoint-picker", + conversation, + }), this.store.putCallback({ kind: "show-reasoning-picker", conversation, @@ -2531,6 +2961,10 @@ export class CodexPluginController { text: "Select Model", callback_data: `${INTERACTIVE_NAMESPACE}:${showModelPicker.token}`, }, + { + text: "Endpoint", + callback_data: `${INTERACTIVE_NAMESPACE}:${showEndpointPicker.token}`, + }, ]; if (currentModel) { topRow.push({ @@ -2674,8 +3108,9 @@ export class CodexPluginController { }, ): Promise { const profile = this.getPermissionsMode(binding); + const client = this.getClientForBinding(binding); const [models, threadState] = await Promise.all([ - this.client.listModels({ profile, sessionKey: binding.sessionKey }), + client.listModels({ profile, sessionKey: binding.sessionKey }), this.readEffectiveThreadState(binding), ]); const { state, effectiveState } = threadState; @@ -2721,6 +3156,58 @@ export class CodexPluginController { }; } + private async buildEndpointPicker( + conversation: ConversationTarget, + binding: StoredBinding | null, + opts?: { + returnToStatus?: boolean; + statusMessage?: InteractiveMessageRef; + }, + ): Promise { + const selectedEndpointId = this.getSelectedEndpointId(conversation, binding); + const buttons: PluginInteractiveButtons = []; + for (const endpoint of this.settings.endpoints) { + const endpointId = endpoint.id ?? this.settings.defaultEndpoint; + const callback = await this.store.putCallback({ + kind: "set-endpoint", + conversation, + endpointId, + returnToStatus: opts?.returnToStatus, + statusMessage: opts?.statusMessage, + }); + const flags = [ + endpointId === selectedEndpointId ? "selected" : "", + binding && endpointId === this.getEndpointIdForBinding(binding) ? "bound" : "", + endpointId === this.settings.defaultEndpoint ? "default" : "", + ].filter(Boolean); + buttons.push([ + { + text: `${endpointId}${flags.length ? ` (${flags.join(", ")})` : ""}`, + callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`, + }, + ]); + } + if (opts?.returnToStatus) { + const cancel = await this.store.putCallback({ + kind: "refresh-status", + conversation, + }); + buttons.push([ + { + text: "Cancel", + callback_data: `${INTERACTIVE_NAMESPACE}:${cancel.token}`, + }, + ]); + } + return { + text: this.formatEndpointListText({ + selectedEndpointId, + binding, + }), + buttons, + }; + } + private async buildReasoningPicker( conversation: ConversationTarget, binding: StoredBinding, @@ -2822,7 +3309,7 @@ export class CodexPluginController { configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); - const skills = dedupeSkillsByName(await this.client.listSkills({ + const skills = dedupeSkillsByName(await this.getClientForBinding(binding).listSkills({ profile: this.getPermissionsMode(binding), sessionKey: binding?.sessionKey, workspaceDir, @@ -3077,7 +3564,7 @@ export class CodexPluginController { void this.sendText(conversation, "Codex is still compacting."); }, COMPACT_PROGRESS_INTERVAL_MS); }, COMPACT_PROGRESS_DELAY_MS); - const result = await this.client.compactThread({ + const result = await this.getClientForBinding(binding).compactThread({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -3133,7 +3620,7 @@ export class CodexPluginController { configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); - const skills = dedupeSkillsByName(await this.client.listSkills({ + const skills = dedupeSkillsByName(await this.getClientForBinding(binding).listSkills({ profile: this.getPermissionsMode(binding), sessionKey: binding?.sessionKey, workspaceDir, @@ -3168,7 +3655,7 @@ export class CodexPluginController { } private async handleExperimentalCommand(binding: StoredBinding | null): Promise { - const features = await this.client.listExperimentalFeatures({ + const features = await this.getClientForBinding(binding).listExperimentalFeatures({ profile: this.getPermissionsMode(binding), sessionKey: binding?.sessionKey, }); @@ -3176,7 +3663,7 @@ export class CodexPluginController { } private async handleMcpCommand(binding: StoredBinding | null, args: string): Promise { - const servers = await this.client.listMcpServers({ + const servers = await this.getClientForBinding(binding).listMcpServers({ profile: this.getPermissionsMode(binding), sessionKey: binding?.sessionKey, }); @@ -3213,7 +3700,7 @@ export class CodexPluginController { action === "toggle" ? (currentTier === "fast" ? null : "fast") : action === "on" ? "fast" : null; - const updatedState = await this.client.setThreadServiceTier({ + const updatedState = await this.getClientForBinding(binding).setThreadServiceTier({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -3245,13 +3732,15 @@ export class CodexPluginController { const trimmedArgs = args.trim(); const profile = this.getPermissionsMode(binding); if (!binding) { - const models = await this.client.listModels({ profile }); + const models = await this.getClientForEndpoint( + this.getSelectedEndpointId(conversation, binding), + ).listModels({ profile }); return { text: formatModels(models) }; } if (!trimmedArgs) { if (!conversation) { const [models, { effectiveState }] = await Promise.all([ - this.client.listModels({ profile, sessionKey: binding.sessionKey }), + this.getClientForBinding(binding).listModels({ profile, sessionKey: binding.sessionKey }), this.readEffectiveThreadState(binding), ]); return { text: formatModels(models, effectiveState) }; @@ -3271,7 +3760,7 @@ export class CodexPluginController { } return buildReplyWithButtons(picker.text, picker.buttons); } - const state = await this.client.setThreadModel({ + const state = await this.getClientForBinding(binding).setThreadModel({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -3282,7 +3771,7 @@ export class CodexPluginController { : "default"; const nextState = !modelSupportsFast(trimmedArgs) && normalizeServiceTier(state.serviceTier) === "fast" - ? await this.client + ? await this.getClientForBinding(binding) .setThreadServiceTier({ profile, sessionKey: binding.sessionKey, @@ -3308,6 +3797,51 @@ export class CodexPluginController { return { text: `Codex model set to ${nextState.model || trimmedArgs}.` }; } + private async handleEndpointCommand( + conversation: ConversationTarget | null, + binding: StoredBinding | null, + args: string, + ): Promise { + if (!conversation) { + return { text: "This command needs a Telegram or Discord conversation." }; + } + const parsed = parseEndpointArgs(args); + if (parsed.error) { + return { text: parsed.error }; + } + const currentSelected = this.getSelectedEndpointId(conversation, binding); + if (!parsed.endpointId) { + const picker = await this.buildEndpointPicker(conversation, binding); + return buildReplyWithButtons(picker.text, picker.buttons); + } + const requested = parsed.endpointId.trim(); + const endpoint = this.settings.endpoints.find((entry) => entry.id === requested); + if (!endpoint) { + return { + text: [ + `Unknown endpoint: ${requested}`, + "", + this.formatEndpointListText({ + selectedEndpointId: currentSelected, + binding, + }), + ].join("\n"), + }; + } + await this.setSelectedEndpointId(conversation, endpoint.id || requested); + const nextSelected = endpoint.id || requested; + const lines = [ + `Selected endpoint set to ${nextSelected}.`, + ]; + if (binding && this.getEndpointIdForBinding(binding) !== nextSelected) { + lines.push( + `This conversation is still bound to a thread on ${this.getEndpointIdForBinding(binding)}. Use /cas_resume to browse/bind on ${nextSelected}.`, + ); + } + lines.push("", this.formatEndpointListText({ selectedEndpointId: nextSelected, binding })); + return { text: lines.join("\n") }; + } + private async handlePermissionsCommand( conversation: ConversationTarget | null, binding: StoredBinding | null, @@ -3354,7 +3888,7 @@ export class CodexPluginController { const picker = await this.buildRenameStylePicker(conversation, binding, Boolean(parsed?.syncTopic)); return buildReplyWithButtons(picker.text, picker.buttons); } - await this.client.setThreadName({ + await this.getClientForBinding(binding).setThreadName({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -3462,7 +3996,7 @@ export class CodexPluginController { if (!name) { throw new Error("Unable to derive a Codex thread name."); } - await this.client.setThreadName({ + await this.getClientForBinding(binding).setThreadName({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -3537,7 +4071,7 @@ export class CodexPluginController { params.binding, this.settings.defaultModel, ); - const run = this.client.startTurn({ + const run = this.getClientForBinding(params.binding).startTurn({ profile, sessionKey: params.binding?.sessionKey, workspaceDir: params.workspaceDir, @@ -3581,7 +4115,7 @@ export class CodexPluginController { .then(async (result) => { const threadId = result.threadId || run.getThreadId(); if (threadId) { - const state = await this.client + const state = await this.getClientForBinding(params.binding) .readThreadState({ profile, sessionKey: params.binding?.sessionKey, @@ -3590,6 +4124,7 @@ export class CodexPluginController { .catch(() => null); const nextBinding = await this.bindConversation(params.conversation, { threadId, + endpointId: this.getEndpointIdForBinding(params.binding), workspaceDir: state?.cwd || params.workspaceDir, threadTitle: state?.threadName, permissionsMode: profile, @@ -3789,7 +4324,7 @@ export class CodexPluginController { this.settings.defaultModel, ); const effectiveThreadState = desired.effectiveState; - const run = this.client.startTurn({ + const run = this.getClientForBinding(params.binding).startTurn({ profile, sessionKey: params.binding?.sessionKey, workspaceDir: params.workspaceDir, @@ -3833,7 +4368,7 @@ export class CodexPluginController { .then(async (result) => { const threadId = result.threadId || run.getThreadId(); if (threadId) { - const state = await this.client + const state = await this.getClientForBinding(params.binding) .readThreadState({ profile, sessionKey: params.binding?.sessionKey, @@ -3842,6 +4377,7 @@ export class CodexPluginController { .catch(() => null); const nextBinding = await this.bindConversation(params.conversation, { threadId, + endpointId: this.getEndpointIdForBinding(params.binding), workspaceDir: state?.cwd || params.workspaceDir, threadTitle: state?.threadName, permissionsMode: profile, @@ -3965,7 +4501,7 @@ export class CodexPluginController { clearTimeout(progressTimer); progressTimer = null; }; - const threadState = await this.client + const threadState = await this.getClientForBinding(params.binding) .readThreadState({ profile, sessionKey: params.binding.sessionKey, @@ -3977,7 +4513,7 @@ export class CodexPluginController { params.binding, this.settings.defaultModel, ); - const run = this.client.startReview({ + const run = this.getClientForBinding(params.binding).startReview({ profile, sessionKey: params.binding.sessionKey, workspaceDir: params.workspaceDir, @@ -4104,10 +4640,12 @@ export class CodexPluginController { } if (state.questionnaire) { const existing = this.store.getPendingRequestById(state.requestId); + const binding = this.store.getBinding(conversation); await this.store.upsertPendingRequest({ requestId: state.requestId, conversation, - threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "", + threadId: run.getThreadId() ?? binding?.threadId ?? "", + endpointId: this.getEndpointIdForBinding(binding), workspaceDir, state, createdAt: existing?.createdAt ?? Date.now(), @@ -4129,10 +4667,12 @@ export class CodexPluginController { ); const buttons = this.buildPendingButtons(state, callbacks); const existing = this.store.getPendingRequestById(state.requestId); + const binding = this.store.getBinding(conversation); await this.store.upsertPendingRequest({ requestId: state.requestId, conversation, - threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "", + threadId: run.getThreadId() ?? binding?.threadId ?? "", + endpointId: this.getEndpointIdForBinding(binding), workspaceDir, state, createdAt: existing?.createdAt ?? Date.now(), @@ -4392,6 +4932,7 @@ export class CodexPluginController { parsed: ReturnType; projectName?: string; filterProjectsOnly?: boolean; + endpointId?: string; }, ) { const workspaceDir = this.resolveThreadWorkspaceDir( @@ -4400,7 +4941,7 @@ export class CodexPluginController { params.filterProjectsOnly || Boolean(params.projectName), ); const profile = this.getPermissionsMode(binding); - const threads = await this.client.listThreads({ + const threads = await this.getClientForEndpoint(params.endpointId ?? this.getEndpointIdForBinding(binding)).listThreads({ profile, sessionKey: binding?.sessionKey, workspaceDir, @@ -4471,6 +5012,7 @@ export class CodexPluginController { parsed: ReturnType; threads: Array<{ threadId: string; title?: string; projectKey?: string }>; showProjectName: boolean; + endpointId?: string; }): Promise { if (params.threads.length === 0) { return undefined; @@ -4482,6 +5024,7 @@ export class CodexPluginController { const callback = await this.store.putCallback({ kind: "resume-thread", conversation: params.conversation, + endpointId: params.endpointId, threadId: thread.threadId, threadTitle: getThreadDisplayTitle(thread), workspaceDir: thread.projectKey?.trim() || this.settings.defaultWorkspaceDir || process.cwd(), @@ -4512,6 +5055,7 @@ export class CodexPluginController { projectName?: string; page: number; totalPages: number; + endpointId?: string; }): Promise { if (params.totalPages > 1) { const navRow: PluginInteractiveButtons[number] = []; @@ -4523,6 +5067,7 @@ export class CodexPluginController { mode: "threads", includeAll: params.parsed.includeAll, syncTopic: params.parsed.syncTopic, + endpointId: params.endpointId, workspaceDir: params.parsed.cwd, query: params.parsed.query || undefined, projectName: params.projectName, @@ -4545,6 +5090,7 @@ export class CodexPluginController { mode: "threads", includeAll: params.parsed.includeAll, syncTopic: params.parsed.syncTopic, + endpointId: params.endpointId, workspaceDir: params.parsed.cwd, query: params.parsed.query || undefined, projectName: params.projectName, @@ -4572,6 +5118,7 @@ export class CodexPluginController { action: "resume-thread", includeAll: true, syncTopic: params.parsed.syncTopic, + endpointId: params.endpointId, workspaceDir: params.parsed.cwd, requestedModel: params.parsed.requestedModel, requestedFast: params.parsed.requestedFast, @@ -4588,6 +5135,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: params.parsed.syncTopic, + endpointId: params.endpointId, workspaceDir: params.parsed.cwd, query: params.parsed.query || undefined, requestedModel: params.parsed.requestedModel, @@ -4628,15 +5176,17 @@ export class CodexPluginController { parsed: ReturnType, page: number, projectName?: string, + endpointId?: string, ): Promise { const profile = this.getPermissionsMode(binding); let { workspaceDir, threads } = await this.listPickerThreads(binding, { parsed, projectName, + endpointId, }); let fallbackToGlobal = false; if (threads.length === 0 && workspaceDir != null && !projectName) { - const globalResult = await this.client.listThreads({ + const globalResult = await this.getClientForEndpoint(endpointId ?? this.getEndpointIdForBinding(binding)).listThreads({ profile, sessionKey: binding?.sessionKey, workspaceDir: undefined, @@ -4657,6 +5207,7 @@ export class CodexPluginController { parsed, threads: pageResult.items, showProjectName: !projectName && (fallbackToGlobal || distinctProjects.size > 1), + endpointId, })) ?? []; return { text: formatThreadPickerIntro({ @@ -4674,6 +5225,7 @@ export class CodexPluginController { buttons: threadButtons, parsed, projectName, + endpointId, page: pageResult.page, totalPages: pageResult.totalPages, }), @@ -4686,10 +5238,12 @@ export class CodexPluginController { parsed: ReturnType, page: number, action: "resume-thread" | "start-new-thread" = "resume-thread", + endpointId?: string, ): Promise { const { workspaceDir, threads } = await this.listPickerThreads(binding, { parsed, filterProjectsOnly: true, + endpointId, }); const normalizedThreads = action === "start-new-thread" ? await this.normalizeNewThreadProjectThreads(threads) : threads; @@ -4704,6 +5258,7 @@ export class CodexPluginController { return this.store.putCallback({ kind: "start-new-thread", conversation, + endpointId, workspaceDir: workspaces[0]?.workspaceDir ?? option.name, syncTopic: parsed.syncTopic, requestedModel: parsed.requestedModel, @@ -4719,6 +5274,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName: option.name, requestedModel: parsed.requestedModel, @@ -4735,6 +5291,7 @@ export class CodexPluginController { mode: "threads", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName: option.name, requestedModel: parsed.requestedModel, @@ -4761,6 +5318,7 @@ export class CodexPluginController { action, includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, query: parsed.query || undefined, requestedModel: parsed.requestedModel, @@ -4848,11 +5406,13 @@ export class CodexPluginController { parsed: ReturnType, page: number, projectName: string, + endpointId?: string, ): Promise { const { threads } = await this.listPickerThreads(binding, { parsed, projectName, filterProjectsOnly: true, + endpointId, }); const normalizedThreads = await this.normalizeNewThreadProjectThreads(threads); const workspaceOptions = paginateItems(listWorkspaceChoices(normalizedThreads, projectName), page); @@ -4861,6 +5421,7 @@ export class CodexPluginController { const callback = await this.store.putCallback({ kind: "start-new-thread", conversation, + endpointId, workspaceDir: option.workspaceDir, syncTopic: parsed.syncTopic, requestedModel: parsed.requestedModel, @@ -4885,6 +5446,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName, requestedModel: parsed.requestedModel, @@ -4907,6 +5469,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName, requestedModel: parsed.requestedModel, @@ -4933,6 +5496,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, requestedModel: parsed.requestedModel, requestedFast: parsed.requestedFast, @@ -4947,6 +5511,7 @@ export class CodexPluginController { mode: "threads", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, requestedModel: parsed.requestedModel, requestedFast: parsed.requestedFast, @@ -5217,6 +5782,7 @@ export class CodexPluginController { const result = await this.startNewThreadAndBindConversation( callback.conversation, this.store.getBinding(callback.conversation), + callback.endpointId, callback.workspaceDir, callback.syncTopic ?? false, { @@ -5241,11 +5807,12 @@ export class CodexPluginController { await responders.clear().catch(() => undefined); } const currentBinding = this.store.getBinding(callback.conversation); + const selectedEndpointId = callback.endpointId ?? this.getSelectedEndpointId(callback.conversation, currentBinding); const profile = this.resolveRequestedPermissionsMode( this.getPermissionsMode(currentBinding), callback.requestedYolo, ); - const threadState = await this.client + const threadState = await this.getClientForEndpoint(selectedEndpointId) .readThreadState({ profile, sessionKey: buildPluginSessionKey(callback.threadId), @@ -5265,6 +5832,7 @@ export class CodexPluginController { callback.conversation, { threadId: callback.threadId, + endpointId: selectedEndpointId, workspaceDir: threadState?.cwd?.trim() || callback.workspaceDir, permissionsMode: profile, threadTitle: threadState?.threadName?.trim() || callback.threadTitle, @@ -5485,7 +6053,7 @@ export class CodexPluginController { const nextTier = currentTier === "fast" ? null : "fast"; let updatedState = threadState; if (threadState) { - updatedState = await this.client.setThreadServiceTier({ + updatedState = await this.getClientForBinding(binding).setThreadServiceTier({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -5615,7 +6183,7 @@ export class CodexPluginController { } const currentProfile = this.getPermissionsMode(binding); const nextProfile = currentProfile === "full-access" ? "default" : "full-access"; - if (nextProfile === "full-access" && !this.hasFullAccessProfile()) { + if (nextProfile === "full-access" && !this.hasFullAccessProfile(binding)) { const unchangedBinding: StoredBinding = { ...binding, updatedAt: Date.now(), @@ -5861,6 +6429,48 @@ export class CodexPluginController { ); return; } + if (callback.kind === "show-endpoint-picker") { + const binding = this.store.getBinding(callback.conversation); + await this.store.removeCallback(callback.token); + const conversation = { + ...callback.conversation, + threadId: responders.conversation.threadId, + }; + if (responders.sourceMessage) { + const [picker, statusCard] = await Promise.all([ + this.buildEndpointPicker( + conversation, + binding, + { + returnToStatus: true, + }, + ), + binding + ? this.buildStatusCard( + conversation, + binding, + true, + ) + : Promise.resolve({ text: this.formatEndpointListText({ selectedEndpointId: this.getSelectedEndpointId(conversation, binding), binding }), buttons: undefined }), + ]); + await responders.editPicker({ + text: statusCard.text, + buttons: picker.buttons, + }); + return; + } + const picker = await this.buildEndpointPicker( + conversation, + binding, + { + returnToStatus: Boolean(binding), + statusMessage: responders.sourceMessage, + }, + ); + await responders.acknowledge?.(); + await this.sendPickerToConversation(conversation, picker); + return; + } if (callback.kind === "show-model-picker") { const binding = this.store.getBinding(callback.conversation); await this.store.removeCallback(callback.token); @@ -5905,6 +6515,52 @@ export class CodexPluginController { await this.sendPickerToConversation(conversation, picker); return; } + if (callback.kind === "set-endpoint") { + const binding = this.store.getBinding(callback.conversation); + await this.store.removeCallback(callback.token); + const conversation = { + ...callback.conversation, + threadId: responders.conversation.threadId, + }; + await this.setSelectedEndpointId(conversation, callback.endpointId); + const refreshedBinding = this.store.getBinding(callback.conversation); + const text = this.buildEndpointSelectionNotice(callback.endpointId, refreshedBinding); + if (callback.returnToStatus && refreshedBinding) { + const statusCard = await this.buildStatusCard( + conversation, + refreshedBinding, + true, + ); + if (responders.sourceMessage) { + await responders.editPicker({ + text: statusCard.text, + buttons: statusCard.buttons, + }); + await this.sendText(conversation, text); + } else { + await responders.acknowledge?.(); + await this.sendText(conversation, statusCard.text, { buttons: statusCard.buttons }); + await this.sendText(conversation, text); + } + return; + } + if (responders.sourceMessage) { + const picker = await this.buildEndpointPicker(conversation, refreshedBinding, { + returnToStatus: false, + }); + await responders.editPicker({ + text, + buttons: picker.buttons, + }); + } else { + await responders.acknowledge?.(); + const picker = await this.buildEndpointPicker(conversation, refreshedBinding, { + returnToStatus: false, + }); + await this.sendText(conversation, text, { buttons: picker.buttons }); + } + return; + } if (callback.kind === "set-model") { const binding = this.store.getBinding(callback.conversation); await this.store.removeCallback(callback.token); @@ -5916,7 +6572,7 @@ export class CodexPluginController { const { state: threadState } = await this.readEffectiveThreadState(binding); let state = threadState; if (threadState) { - state = await this.client.setThreadModel({ + state = await this.getClientForBinding(binding).setThreadModel({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -5933,7 +6589,7 @@ export class CodexPluginController { : "default"; let nextState = state; if (!modelSupportsFast(callback.model) && normalizeServiceTier(state?.serviceTier) === "fast") { - nextState = await this.client + nextState = await this.getClientForBinding(binding) .setThreadServiceTier({ profile, sessionKey: binding.sessionKey, @@ -6058,6 +6714,7 @@ export class CodexPluginController { parsed!, callback.view.page, callback.view.action ?? "resume-thread", + callback.view.endpointId, ) : callback.view.mode === "workspaces" ? await this.renderNewThreadWorkspacePicker( @@ -6066,6 +6723,7 @@ export class CodexPluginController { parsed!, callback.view.page, callback.view.projectName, + callback.view.endpointId, ) : callback.view.mode === "skills" ? await this.buildSkillsPicker( @@ -6083,6 +6741,7 @@ export class CodexPluginController { parsed!, callback.view.page, callback.view.projectName, + callback.view.endpointId, ); await responders.editPicker(picker); } @@ -6090,6 +6749,7 @@ export class CodexPluginController { private async startNewThreadAndBindConversation( conversation: ConversationTarget, binding: StoredBinding | null, + endpointId: string | undefined, workspaceDir: string, syncTopic: boolean, overrides: CommandPreferenceOverrides, @@ -6103,7 +6763,8 @@ export class CodexPluginController { this.getPermissionsMode(binding), overrides.requestedYolo, ); - const created = await this.client.startThread({ + const resolvedEndpointId = endpointId ?? this.getSelectedEndpointId(conversation, binding); + const created = await this.getClientForEndpoint(resolvedEndpointId).startThread({ profile, sessionKey: binding?.sessionKey, workspaceDir, @@ -6118,6 +6779,7 @@ export class CodexPluginController { conversation, { threadId: created.threadId, + endpointId: resolvedEndpointId, workspaceDir: created.cwd?.trim() || workspaceDir, threadTitle: created.threadName, permissionsMode: profile, @@ -6148,6 +6810,7 @@ export class CodexPluginController { } private async resolveSingleThread( + endpointId: string | undefined, sessionKey: string | undefined, workspaceDir: string | undefined, filter: string, @@ -6157,7 +6820,7 @@ export class CodexPluginController { | { kind: "ambiguous"; threads: Array<{ threadId: string; title?: string; projectKey?: string }> } > { const trimmed = filter.trim(); - const threads = await this.client.listThreads({ + const threads = await this.getClientForEndpoint(endpointId).listThreads({ profile: "default", sessionKey, workspaceDir, @@ -6185,11 +6848,11 @@ export class CodexPluginController { binding: StoredBinding, profile: PermissionsMode, ): Promise { - if (profile === "full-access" && !this.hasFullAccessProfile()) { + if (profile === "full-access" && !this.hasFullAccessProfile(binding)) { throw new Error("Full Access is unavailable in the current Codex Desktop session."); } const preferredPermissions = getPermissionsForMode(profile); - const state = await this.client + const state = await this.getClientForBinding(binding) .setThreadPermissions({ profile, sessionKey: binding.sessionKey, @@ -6198,7 +6861,7 @@ export class CodexPluginController { sandbox: preferredPermissions.sandbox, }) .catch(() => - this.client.readThreadState({ + this.getClientForBinding(binding).readThreadState({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -6242,6 +6905,7 @@ export class CodexPluginController { conversation: ConversationTarget, params: { threadId: string; + endpointId?: string; workspaceDir: string; threadTitle?: string; permissionsMode?: PermissionsMode; @@ -6260,6 +6924,7 @@ export class CodexPluginController { }, sessionKey, threadId: params.threadId, + endpointId: params.endpointId ?? existing?.endpointId ?? this.settings.defaultEndpoint, workspaceDir: params.workspaceDir, permissionsMode: params.permissionsMode ?? existing?.permissionsMode ?? "default", pendingPermissionsMode: params.pendingPermissionsMode ?? existing?.pendingPermissionsMode, @@ -6288,6 +6953,7 @@ export class CodexPluginController { } const binding = await this.bindConversation(conversation, { threadId: pending.threadId, + endpointId: pending.endpointId, workspaceDir: pending.workspaceDir, threadTitle: pending.threadTitle, permissionsMode: normalizePermissionsMode(pending.permissionsMode), @@ -6300,6 +6966,7 @@ export class CodexPluginController { conversation: ConversationTarget, params: { threadId: string; + endpointId?: string; workspaceDir: string; permissionsMode?: PermissionsMode; threadTitle?: string; @@ -6344,6 +7011,7 @@ export class CodexPluginController { parentConversationId: conversation.parentConversationId, }, threadId: params.threadId, + endpointId: params.endpointId ?? this.settings.defaultEndpoint, workspaceDir: params.workspaceDir, permissionsMode: params.permissionsMode, threadTitle: params.threadTitle, @@ -6415,7 +7083,7 @@ export class CodexPluginController { const readStateForRestore = async (): Promise => { try { - return await this.client.readThreadState({ + return await this.getClientForBinding(binding).readThreadState({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -6436,7 +7104,7 @@ export class CodexPluginController { lastAssistantMessage?: string; }> => { try { - return await this.client.readThreadContext({ + return await this.getClientForBinding(binding).readThreadContext({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -6573,6 +7241,7 @@ export class CodexPluginController { binding: StoredBinding | null, bindingActive: boolean, ): Promise { + const selectedEndpointId = this.getSelectedEndpointId(conversation, binding); const activeRun = bindingActive && conversation ? this.activeRuns.get(buildConversationKey(conversation)) @@ -6585,18 +7254,19 @@ export class CodexPluginController { serviceWorkspaceDir: this.serviceWorkspaceDir, }); const [threadState, account, limits, projectFolder] = await Promise.all([ + binding - ? this.client.readThreadState({ + ? this.getClientForBinding(binding).readThreadState({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, }).catch(() => undefined) : Promise.resolve(undefined), - this.client.readAccount({ + this.getClientForEndpoint(selectedEndpointId).readAccount({ profile, sessionKey: binding?.sessionKey, }).catch(() => null), - this.client.readRateLimits({ + this.getClientForEndpoint(selectedEndpointId).readRateLimits({ profile, sessionKey: binding?.sessionKey, }).catch(() => []), @@ -6616,12 +7286,17 @@ export class CodexPluginController { binding && !threadState ? "Live thread details are unavailable until Codex materializes the thread, usually after the first user message. Model, reasoning, and fast-mode changes made here are saved as defaults until then." : undefined; + const endpointNote = + binding && this.getEndpointIdForBinding(binding) !== selectedEndpointId + ? `Selected endpoint ${selectedEndpointId} differs from the bound endpoint ${this.getEndpointIdForBinding(binding)}.` + : undefined; this.api.logger.debug?.( `codex status snapshot bindingActive=${bindingActive ? "yes" : "no"} activeRun=${activeRun?.mode ?? "none"} boundThread=${binding?.threadId ?? ""} raw=${formatThreadStateForLog(threadState)} effective=${formatThreadStateForLog(displayThreadState)} ${formatBindingPreferencesForLog(binding)} threadCwd=${displayThreadState?.cwd?.trim() || ""}`, ); return formatCodexStatusText({ pluginVersion: PLUGIN_VERSION, + endpointId: selectedEndpointId, threadState: displayThreadState, bindingThreadTitle: binding?.threadTitle, account, @@ -6633,7 +7308,16 @@ export class CodexPluginController { planMode: bindingActive ? activeRun?.mode === "plan" : undefined, threadNote, permissionNote: - pendingProfile && activeRun + endpointNote + ? [ + pendingProfile && activeRun + ? buildPendingPermissionsMigrationNote(pendingProfile) + : undefined, + endpointNote, + ] + .filter(Boolean) + .join(" ") + : pendingProfile && activeRun ? buildPendingPermissionsMigrationNote(pendingProfile) : undefined, }); diff --git a/src/format.ts b/src/format.ts index 41c34f4..6f36314 100644 --- a/src/format.ts +++ b/src/format.ts @@ -525,6 +525,7 @@ export function formatCodexContextUsageSnapshot( export function formatCodexStatusText(params: { pluginVersion?: string; + endpointId?: string; threadState?: ThreadState; bindingThreadTitle?: string; account?: AccountSummary | null; @@ -550,6 +551,9 @@ export function formatCodexStatusText(params: { if (params.pluginVersion?.trim()) { lines.push(`Plugin version: ${params.pluginVersion.trim()}`); } + if (params.endpointId?.trim()) { + lines.push(`Endpoint: ${params.endpointId.trim()}`); + } if (params.threadState) { lines.push(`Model: ${formatCodexModelText(params.threadState)}`); } @@ -616,6 +620,7 @@ export function formatBoundThreadSummary(params: { params.binding.threadTitle?.trim(); const parts = [ "Codex thread bound.", + params.binding.endpointId ? `Endpoint: ${params.binding.endpointId}` : "", `Project: ${projectName}`, threadName ? `Thread Name: ${threadName}` : "", `Thread ID: ${params.binding.threadId}`, diff --git a/src/help.ts b/src/help.ts index 8a269d4..dc9411f 100644 --- a/src/help.ts +++ b/src/help.ts @@ -147,6 +147,17 @@ export const COMMAND_HELP: Record = { ], notes: "The status card is the main interactive model-control surface, but this command remains available.", }, + cas_endpoint: { + summary: COMMAND_SUMMARY.cas_endpoint, + usage: "/cas_endpoint [endpoint_id]", + flags: [{ flag: "[endpoint_id]", description: "Show the active endpoint or switch this conversation to a configured endpoint id." }], + examples: [ + "/cas_endpoint", + "/cas_endpoint primary", + "/cas_endpoint backup", + ], + notes: "Changing the selected endpoint affects future /cas_resume and unbound CAS actions. Existing bindings stay attached to their original endpoint until you resume/bind there again.", + }, cas_permissions: { summary: COMMAND_SUMMARY.cas_permissions, usage: "/cas_permissions", diff --git a/src/state.ts b/src/state.ts index 8cdc282..ff0d0c7 100644 --- a/src/state.ts +++ b/src/state.ts @@ -10,6 +10,7 @@ import type { PermissionsMode, StoreSnapshot, StoredBinding, + StoredConversationEndpoint, StoredPendingBind, StoredPendingRequest, } from "./types.js"; @@ -18,6 +19,7 @@ type PutCallbackInput = | { kind: "start-new-thread"; conversation: ConversationTarget; + endpointId?: string; workspaceDir: string; syncTopic?: boolean; requestedModel?: string; @@ -29,6 +31,7 @@ type PutCallbackInput = | { kind: "resume-thread"; conversation: ConversationTarget; + endpointId?: string; threadId: string; threadTitle?: string; workspaceDir: string; @@ -167,6 +170,12 @@ type PutCallbackInput = token?: string; ttlMs?: number; } + | { + kind: "show-endpoint-picker"; + conversation: ConversationTarget; + token?: string; + ttlMs?: number; + } | { kind: "set-model"; conversation: ConversationTarget; @@ -176,6 +185,15 @@ type PutCallbackInput = token?: string; ttlMs?: number; } + | { + kind: "set-endpoint"; + conversation: ConversationTarget; + endpointId: string; + returnToStatus?: boolean; + statusMessage?: Extract["statusMessage"]; + token?: string; + ttlMs?: number; + } | { kind: "reply-text"; conversation: ConversationTarget; @@ -204,6 +222,7 @@ function cloneSnapshot(value?: Partial): StoreSnapshot { return { version: STORE_VERSION, bindings: value?.bindings ?? [], + conversationEndpoints: value?.conversationEndpoints ?? [], pendingBinds: value?.pendingBinds ?? [], pendingRequests: value?.pendingRequests ?? [], callbacks: value?.callbacks ?? [], @@ -263,6 +282,7 @@ function normalizeSnapshot(value?: Partial): StoreSnapshot { | undefined; return { ...binding, + endpointId: binding.endpointId?.trim() || "default", permissionsMode: inferPermissionsModeFromLegacyFields({ permissionsMode: (binding as StoredBinding & { permissionsMode?: string }).permissionsMode, appServerProfile: (binding as StoredBinding & { appServerProfile?: string }).appServerProfile, @@ -288,6 +308,7 @@ function normalizeSnapshot(value?: Partial): StoreSnapshot { | undefined; return { ...entry, + endpointId: entry.endpointId?.trim() || "default", permissionsMode: inferPermissionsModeFromLegacyFields({ permissionsMode: (entry as StoredPendingBind & { permissionsMode?: string }).permissionsMode, appServerProfile: (entry as StoredPendingBind & { appServerProfile?: string }).appServerProfile, @@ -297,6 +318,16 @@ function normalizeSnapshot(value?: Partial): StoreSnapshot { preferences: normalizeConversationPreferences(legacyPreferences), }; }); + snapshot.pendingRequests = snapshot.pendingRequests.map((entry) => ({ + ...entry, + endpointId: entry.endpointId?.trim() || "default", + })); + snapshot.conversationEndpoints = snapshot.conversationEndpoints + .map((entry) => ({ + ...entry, + endpointId: entry.endpointId?.trim() || "default", + })) + .filter((entry) => entry.endpointId); return snapshot; } @@ -354,6 +385,24 @@ export class PluginStateStore { return this.snapshot.bindings.find((entry) => toConversationKey(entry.conversation) === key) ?? null; } + getConversationEndpoint(target: ConversationTarget): StoredConversationEndpoint | null { + const key = toConversationKey(target); + return ( + this.snapshot.conversationEndpoints.find( + (entry) => toConversationKey(entry.conversation as ConversationTarget) === key, + ) ?? null + ); + } + + async upsertConversationEndpoint(entry: StoredConversationEndpoint): Promise { + const key = toConversationKey(entry.conversation as ConversationTarget); + this.snapshot.conversationEndpoints = this.snapshot.conversationEndpoints.filter( + (current) => toConversationKey(current.conversation as ConversationTarget) !== key, + ); + this.snapshot.conversationEndpoints.push(entry); + await this.save(); + } + async upsertBinding(binding: StoredBinding): Promise { const key = toConversationKey(binding.conversation); this.snapshot.bindings = this.snapshot.bindings.filter( @@ -452,6 +501,7 @@ export class PluginStateStore { ? { kind: "start-new-thread", conversation: callback.conversation, + endpointId: callback.endpointId, workspaceDir: callback.workspaceDir, syncTopic: callback.syncTopic, requestedModel: callback.requestedModel, @@ -465,6 +515,7 @@ export class PluginStateStore { ? { kind: "resume-thread", conversation: callback.conversation, + endpointId: callback.endpointId, threadId: callback.threadId, threadTitle: callback.threadTitle, workspaceDir: callback.workspaceDir, @@ -651,6 +702,25 @@ export class PluginStateStore { createdAt: now, expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS), } + : callback.kind === "show-endpoint-picker" + ? { + kind: "show-endpoint-picker", + conversation: callback.conversation, + token: callback.token ?? this.createCallbackToken(), + createdAt: now, + expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS), + } + : callback.kind === "set-endpoint" + ? { + kind: "set-endpoint", + conversation: callback.conversation, + endpointId: callback.endpointId, + returnToStatus: callback.returnToStatus, + statusMessage: callback.statusMessage, + token: callback.token ?? this.createCallbackToken(), + createdAt: now, + expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS), + } : callback.kind === "reply-text" ? { kind: "reply-text", diff --git a/src/types.ts b/src/types.ts index f6e161f..e776fb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,7 @@ import type { ConversationRef, PluginInteractiveButtons } from "openclaw/plugin- export const PLUGIN_ID = "openclaw-codex-app-server"; export const INTERACTIVE_NAMESPACE = "codexapp"; -export const STORE_VERSION = 2; +export const STORE_VERSION = 3; export const CALLBACK_TOKEN_BYTES = 9; export const CALLBACK_TTL_MS = 30 * 60_000; export const PENDING_INPUT_TTL_MS = 7 * 24 * 60 * 60_000; @@ -11,14 +11,20 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; export type CodexTransport = "stdio" | "websocket"; export type PermissionsMode = "default" | "full-access"; -export type PluginSettings = { - enabled: boolean; +export type EndpointSettings = { + id?: string; transport: CodexTransport; command: string; args: string[]; url?: string; headers?: Record; requestTimeoutMs: number; +}; + +export type PluginSettings = { + enabled: boolean; + defaultEndpoint: string; + endpoints: EndpointSettings[]; defaultWorkspaceDir?: string; defaultModel?: string; defaultServiceTier?: string; @@ -274,6 +280,7 @@ export type StoredBinding = { conversation: ConversationRef; sessionKey: string; threadId: string; + endpointId?: string; workspaceDir: string; permissionsMode?: PermissionsMode; pendingPermissionsMode?: PermissionsMode; @@ -299,6 +306,7 @@ export type InteractiveMessageRef = export type StoredPendingBind = { conversation: ConversationRef; threadId: string; + endpointId?: string; workspaceDir: string; permissionsMode?: PermissionsMode; threadTitle?: string; @@ -312,17 +320,25 @@ export type StoredPendingRequest = { requestId: string; conversation: ConversationRef; threadId: string; + endpointId?: string; workspaceDir: string; state: PendingInputState; createdAt?: number; updatedAt: number; }; +export type StoredConversationEndpoint = { + conversation: ConversationRef; + endpointId: string; + updatedAt: number; +}; + export type CallbackAction = | { token: string; kind: "start-new-thread"; conversation: ConversationRef; + endpointId?: string; workspaceDir: string; syncTopic?: boolean; requestedModel?: string; @@ -335,6 +351,7 @@ export type CallbackAction = token: string; kind: "resume-thread"; conversation: ConversationRef; + endpointId?: string; threadId: string; threadTitle?: string; workspaceDir: string; @@ -375,6 +392,7 @@ export type CallbackAction = includeAll: boolean; page: number; syncTopic?: boolean; + endpointId?: string; query?: string; workspaceDir?: string; projectName?: string; @@ -388,6 +406,7 @@ export type CallbackAction = includeAll: boolean; page: number; syncTopic?: boolean; + endpointId?: string; query?: string; workspaceDir?: string; projectName?: string; @@ -401,6 +420,7 @@ export type CallbackAction = includeAll: boolean; page: number; syncTopic?: boolean; + endpointId?: string; workspaceDir?: string; projectName: string; requestedModel?: string; @@ -525,6 +545,13 @@ export type CallbackAction = createdAt: number; expiresAt: number; } + | { + token: string; + kind: "show-endpoint-picker"; + conversation: ConversationRef; + createdAt: number; + expiresAt: number; + } | { token: string; kind: "set-model"; @@ -535,6 +562,16 @@ export type CallbackAction = createdAt: number; expiresAt: number; } + | { + token: string; + kind: "set-endpoint"; + conversation: ConversationRef; + endpointId: string; + returnToStatus?: boolean; + statusMessage?: InteractiveMessageRef; + createdAt: number; + expiresAt: number; + } | { token: string; kind: "reply-text"; @@ -563,6 +600,7 @@ export type CallbackAction = export type StoreSnapshot = { version: number; bindings: StoredBinding[]; + conversationEndpoints: StoredConversationEndpoint[]; pendingBinds: StoredPendingBind[]; pendingRequests: StoredPendingRequest[]; callbacks: CallbackAction[]; From e526fb825f9bf647fbd1142420bf6d45de0f9e3a Mon Sep 17 00:00:00 2001 From: Yehonal Date: Wed, 22 Apr 2026 22:31:53 +0000 Subject: [PATCH 03/19] feat: route CAS worker tools from exec node context --- README.md | 1 + openclaw.plugin.json | 16 ++++++++++ src/agent-tools.ts | 37 +++++++++++++++++++++- src/config.ts | 2 ++ src/controller.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++-- src/controller.ts | 52 +++++++++++++++++++++++++------ src/types.ts | 1 + 7 files changed, 165 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9bf0606..e6fd111 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ The plugin schema in [`openclaw.plugin.json`](./openclaw.plugin.json) supports: - `transport`: `stdio` or `websocket` - `command` and `args`: the Codex executable and CLI args for `stdio` +- `execNodes`: optional list of `tools.exec.node` aliases that should auto-select a specific endpoint when agent tools run with `tools.exec.host=node` - `url`, `authToken`, `headers`: connection settings for `websocket` - `defaultWorkspaceDir`: fallback workspace for unbound actions - `defaultModel`: model used when a new thread starts without an explicit selection diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 20bab45..88875f3 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -19,6 +19,12 @@ "command": { "type": "string" }, + "execNodes": { + "type": "array", + "items": { + "type": "string" + } + }, "args": { "type": "array", "items": { @@ -63,6 +69,12 @@ "command": { "type": "string" }, + "execNodes": { + "type": "array", + "items": { + "type": "string" + } + }, "args": { "type": "array", "items": { @@ -114,6 +126,10 @@ "label": "Codex Command", "help": "Used for stdio mode. Defaults to codex." }, + "execNodes": { + "label": "Exec Node Aliases", + "advanced": true + }, "args": { "label": "Codex Args", "advanced": true diff --git a/src/agent-tools.ts b/src/agent-tools.ts index f533e75..51a38df 100644 --- a/src/agent-tools.ts +++ b/src/agent-tools.ts @@ -34,6 +34,28 @@ function readBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +function readToolExecContext(ctx: { + runtimeConfig?: { + tools?: { + exec?: { + host?: string; + node?: string; + }; + }; + }; +} | undefined): { host?: string; node?: string } | undefined { + const exec = ctx?.runtimeConfig?.tools?.exec; + const host = readString(exec?.host); + const node = readString(exec?.node); + if (!host && !node) { + return undefined; + } + return { + host, + node, + }; +} + function readInputItems(value: unknown): | Array<{ type: "text"; text: string } | { type: "image"; url: string } | { type: "localImage"; path: string }> | undefined { @@ -68,7 +90,17 @@ function readInputItems(value: unknown): } export function createAgentTools(controller: CodexPluginController) { - type ToolCtx = { sessionKey?: string } | undefined; + type ToolCtx = { + sessionKey?: string; + runtimeConfig?: { + tools?: { + exec?: { + host?: string; + node?: string; + }; + }; + }; + } | undefined; return [ { @@ -113,6 +145,7 @@ export function createAgentTools(controller: CodexPluginController) { ...(await controller.listAgentThreads({ sessionKey: ctx?.sessionKey, endpointId: readString(record.endpointId), + execContext: readToolExecContext(ctx), workspaceDir: readString(record.workspaceDir), includeAllWorkspaces: readBoolean(record.includeAllWorkspaces), filter: readString(record.filter), @@ -178,6 +211,7 @@ export function createAgentTools(controller: CodexPluginController) { ...(await controller.runAgentTask({ sessionKey: ctx?.sessionKey, endpointId: readString(record.endpointId), + execContext: readToolExecContext(ctx), prompt, workspaceDir: readString(record.workspaceDir), threadId: readString(record.threadId), @@ -243,6 +277,7 @@ export function createAgentTools(controller: CodexPluginController) { ...(await controller.readAgentThreadContext({ sessionKey: ctx?.sessionKey, endpointId: readString(record.endpointId), + execContext: readToolExecContext(ctx), threadId, permissionsMode: readString(record.permissionsMode) === "full-access" ? "full-access" : "default", })), diff --git a/src/config.ts b/src/config.ts index afd33c2..f1d2e2e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,6 +83,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { const fallbackId = index === 0 ? "default" : `endpoint-${index + 1}`; return { id: normalizeEndpointId(readString(entry, "id"), fallbackId), + execNodes: readStringArray(entry, "execNodes"), transport, command: readString(entry, "command") ?? "codex", args: readStringArray(entry, "args"), @@ -107,6 +108,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { : [ { id: "default", + execNodes: readStringArray(record, "execNodes"), transport: legacyTransport, command: readString(record, "command") ?? "codex", args: readStringArray(record, "args"), diff --git a/src/controller.test.ts b/src/controller.test.ts index af9973e..8ab49e2 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -42,7 +42,7 @@ function makeStateDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-app-server-test-")); } -function createApiMock() { +function createApiMock(pluginConfigOverrides: Record = {}) { const stateDir = makeStateDir(); const sendComponentMessage = vi.fn(async (..._args: unknown[]) => ({ messageId: "discord-component-1", channelId: "channel:chan-1" })); const sendMessageDiscord = vi.fn(async (..._args: unknown[]) => ({ messageId: "discord-msg-1", channelId: "channel:chan-1" })); @@ -131,6 +131,7 @@ function createApiMock() { pluginConfig: { enabled: true, defaultWorkspaceDir: "/repo/openclaw", + ...pluginConfigOverrides, }, logger: { debug: vi.fn(), @@ -205,7 +206,7 @@ function createApiMock() { }; } -async function createControllerHarness() { +async function createControllerHarness(pluginConfigOverrides: Record = {}) { const { api, sendComponentMessage, @@ -217,7 +218,7 @@ async function createControllerHarness() { editChannel, discordOutbound, stateDir, - } = createApiMock(); + } = createApiMock(pluginConfigOverrides); const controller = new CodexPluginController(api); await controller.start(); const threadState: any = { @@ -6815,4 +6816,66 @@ describe("Discord controller flows", () => { // The callback should be removed from the store expect((controller as any).store.getCallback(callback.token)).toBeNull(); }); + + it("auto-selects the matching endpoint when exec host=node and node matches endpoint id", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + expect((controller as any).resolveAgentEndpointId(undefined, { host: "node", node: "nestdev" })).toBe("nestdev"); + }); + + it("auto-selects the matching endpoint when exec node matches an endpoint alias", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev-cas", + execNodes: ["nestdev", "node-123"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + expect((controller as any).resolveAgentEndpointId(undefined, { host: "node", node: "node-123" })).toBe("nestdev-cas"); + }); + + it("keeps the configured default endpoint when exec host is not node", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + expect((controller as any).resolveAgentEndpointId(undefined, { host: "gateway", node: "nestdev" })).toBe("default"); + }); }); diff --git a/src/controller.ts b/src/controller.ts index c75ecf8..d0a0226 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1323,6 +1323,11 @@ type WorkspaceChoice = { latestUpdatedAt?: number; }; +type AgentExecContext = { + host?: string; + node?: string; +}; + function listWorkspaceChoices( threads: Array<{ projectKey?: string; createdAt?: number; updatedAt?: number }>, projectName?: string, @@ -1434,6 +1439,7 @@ export class CodexPluginController { defaultModel: string | null; endpoints: Array<{ id: string; + execNodes: string[]; transport: string; url: string | null; command: string; @@ -1449,6 +1455,7 @@ export class CodexPluginController { defaultModel: this.settings.defaultModel ?? null, endpoints: this.settings.endpoints.map((endpoint, index) => ({ id: endpoint.id ?? `endpoint-${index + 1}`, + execNodes: [...(endpoint.execNodes ?? [])], transport: endpoint.transport, url: endpoint.url ?? null, command: endpoint.command, @@ -1462,6 +1469,7 @@ export class CodexPluginController { async listAgentThreads(params: { sessionKey?: string; endpointId?: string; + execContext?: AgentExecContext; workspaceDir?: string; includeAllWorkspaces?: boolean; filter?: string; @@ -1474,7 +1482,7 @@ export class CodexPluginController { threads: Awaited>; }> { await this.start(); - const endpointId = this.resolveAgentEndpointId(params.endpointId); + const endpointId = this.resolveAgentEndpointId(params.endpointId, params.execContext); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = params.includeAllWorkspaces ? undefined @@ -1501,6 +1509,7 @@ export class CodexPluginController { async readAgentThreadContext(params: { sessionKey?: string; endpointId?: string; + execContext?: AgentExecContext; threadId: string; permissionsMode?: PermissionsMode; }): Promise<{ @@ -1511,7 +1520,7 @@ export class CodexPluginController { context: Awaited>; }> { await this.start(); - const endpointId = this.resolveAgentEndpointId(params.endpointId); + const endpointId = this.resolveAgentEndpointId(params.endpointId, params.execContext); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const threadId = params.threadId.trim(); const client = this.getClientForEndpoint(endpointId); @@ -1539,6 +1548,7 @@ export class CodexPluginController { async runAgentTask(params: { sessionKey?: string; endpointId?: string; + execContext?: AgentExecContext; prompt: string; workspaceDir?: string; threadId?: string; @@ -1562,7 +1572,7 @@ export class CodexPluginController { result: TurnResult; }> { await this.start(); - const endpointId = this.resolveAgentEndpointId(params.endpointId); + const endpointId = this.resolveAgentEndpointId(params.endpointId, params.execContext); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = resolveWorkspaceDir({ requested: params.workspaceDir, @@ -1662,15 +1672,39 @@ export class CodexPluginController { }; } - private resolveAgentEndpointId(endpointId?: string): string { + private resolveAgentEndpointId(endpointId?: string, execContext?: AgentExecContext): string { const requested = endpointId?.trim(); - if (!requested) { - return this.settings.defaultEndpoint; + if (requested) { + if (!this.settings.endpoints.some((entry) => entry.id === requested)) { + throw new Error(`Unknown Codex endpoint: ${requested}`); + } + return requested; } - if (!this.settings.endpoints.some((entry) => entry.id === requested)) { - throw new Error(`Unknown Codex endpoint: ${requested}`); + const inferred = this.resolveEndpointIdFromExecContext(execContext); + if (inferred) { + return inferred; } - return requested; + return this.settings.defaultEndpoint; + } + + private resolveEndpointIdFromExecContext(execContext?: AgentExecContext): string | undefined { + const host = execContext?.host?.trim().toLowerCase(); + if (host !== "node") { + return undefined; + } + const node = execContext?.node?.trim(); + if (!node) { + return undefined; + } + const normalizedNode = node.toLowerCase(); + const exactMatch = this.settings.endpoints.find((entry) => entry.id?.trim().toLowerCase() === normalizedNode); + if (exactMatch?.id) { + return exactMatch.id; + } + const aliasMatch = this.settings.endpoints.find((entry) => + (entry.execNodes ?? []).some((alias) => alias.trim().toLowerCase() === normalizedNode), + ); + return aliasMatch?.id; } private resolveAgentPermissionsMode( diff --git a/src/types.ts b/src/types.ts index e776fb8..47ff744 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,7 @@ export type PermissionsMode = "default" | "full-access"; export type EndpointSettings = { id?: string; + execNodes?: string[]; transport: CodexTransport; command: string; args: string[]; From 5819e31036773c19cc5c560c19b879331c698b74 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 23 Apr 2026 05:46:20 +0000 Subject: [PATCH 04/19] feat: add manual and automatic CAS endpoint policy --- src/commands.ts | 1 + src/controller.test.ts | 133 ++++++++++++++++++++++++ src/controller.ts | 228 ++++++++++++++++++++++++++++++++++------- src/format.ts | 6 +- src/help.ts | 13 ++- src/state.ts | 26 +++++ src/types.ts | 9 ++ 7 files changed, 374 insertions(+), 42 deletions(-) diff --git a/src/commands.ts b/src/commands.ts index d341993..85ebac5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -12,6 +12,7 @@ export const COMMANDS = [ ["cas_mcp", "List Codex MCP servers."], ["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_endpoints", "List configured Codex endpoints and show the active endpoint policy for this conversation."], ["cas_endpoint", "Show or switch the active Codex endpoint for this conversation."], ["cas_permissions", "Show Codex permissions and account status."], ["cas_init", "Forward /init to Codex."], diff --git a/src/controller.test.ts b/src/controller.test.ts index 8ab49e2..62e0b84 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -6878,4 +6878,137 @@ describe("Discord controller flows", () => { expect((controller as any).resolveAgentEndpointId(undefined, { host: "gateway", node: "nestdev" })).toBe("default"); }); + + it("prefers a manual conversation endpoint over automatic node resolution", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + (controller as any).lastRuntimeConfig = { + tools: { + exec: { + host: "node", + node: "nestdev", + }, + }, + }; + await (controller as any).store.upsertConversationEndpoint({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + endpointId: "default", + updatedAt: Date.now(), + }); + + expect( + (controller as any).getSelectedEndpointResolution({ + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }), + ).toMatchObject({ endpointId: "default", source: "manual" }); + }); + + it("clears the manual endpoint override and falls back to automatic node resolution", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + await (controller as any).store.upsertConversationEndpoint({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + endpointId: "default", + updatedAt: Date.now(), + }); + + const reply = await controller.handleCommand( + "cas_endpoint", + buildDiscordCommandContext({ + args: "auto", + commandBody: "/cas_endpoint auto", + config: { + tools: { + exec: { + host: "node", + node: "nestdev", + }, + }, + }, + }), + ); + + expect((controller as any).store.getConversationEndpoint({ + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + })).toBeNull(); + expect(reply.text).toContain("Manual endpoint override cleared"); + expect(reply.text).toContain("Active endpoint: nestdev (auto from node: nestdev)"); + }); + + it("supports cas_endpoints as a direct endpoint inspection alias", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + const reply = await controller.handleCommand( + "cas_endpoints", + buildDiscordCommandContext({ + commandBody: "/cas_endpoints", + config: { + tools: { + exec: { + host: "node", + node: "nestdev", + }, + }, + }, + }), + ); + + expect(reply.text).toContain("Active endpoint: nestdev (auto from node: nestdev)"); + expect(reply.text).toContain("Configured endpoints:"); + }); }); diff --git a/src/controller.ts b/src/controller.ts index d0a0226..ea68082 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1328,6 +1328,12 @@ type AgentExecContext = { node?: string; }; +type EndpointResolution = { + endpointId: string; + source: "manual" | "auto-node" | "default"; + nodeId?: string; +}; + function listWorkspaceChoices( threads: Array<{ projectKey?: string; createdAt?: number; updatedAt?: number }>, projectName?: string, @@ -1726,17 +1732,71 @@ export class CodexPluginController { return this.settings.defaultEndpoint; } + private readExecContextFromConfig(config: unknown): AgentExecContext | undefined { + if (!config || typeof config !== "object" || Array.isArray(config)) { + return undefined; + } + const tools = (config as { tools?: unknown }).tools; + if (!tools || typeof tools !== "object" || Array.isArray(tools)) { + return undefined; + } + const exec = (tools as { exec?: unknown }).exec; + if (!exec || typeof exec !== "object" || Array.isArray(exec)) { + return undefined; + } + const host = typeof (exec as { host?: unknown }).host === "string" + ? (exec as { host?: string }).host?.trim() + : undefined; + const node = typeof (exec as { node?: unknown }).node === "string" + ? (exec as { node?: string }).node?.trim() + : undefined; + if (!host && !node) { + return undefined; + } + return { host, node }; + } + + private getManualEndpointId(conversation: ConversationTarget | null | undefined): string | undefined { + if (!conversation) { + return undefined; + } + const stored = this.store.getConversationEndpoint(conversation)?.endpointId?.trim(); + if (stored && this.settings.endpoints.some((entry) => entry.id === stored)) { + return stored; + } + return undefined; + } + private getSelectedEndpointId( conversation: ConversationTarget | null | undefined, - binding?: StoredBinding | StoredPendingBind | null, + _binding?: StoredBinding | StoredPendingBind | null, ): string { - if (conversation) { - const stored = this.store.getConversationEndpoint(conversation)?.endpointId?.trim(); - if (stored && this.settings.endpoints.some((entry) => entry.id === stored)) { - return stored; - } + return this.getSelectedEndpointResolution(conversation).endpointId; + } + + private getSelectedEndpointResolution( + conversation: ConversationTarget | null | undefined, + ): EndpointResolution { + const manualEndpointId = this.getManualEndpointId(conversation); + if (manualEndpointId) { + return { + endpointId: manualEndpointId, + source: "manual", + }; } - return this.getEndpointIdForBinding(binding); + const execContext = this.readExecContextFromConfig(this.getOpenClawConfig()); + const autoEndpointId = this.resolveEndpointIdFromExecContext(execContext); + if (autoEndpointId) { + return { + endpointId: autoEndpointId, + source: "auto-node", + nodeId: execContext?.node?.trim() || undefined, + }; + } + return { + endpointId: this.settings.defaultEndpoint, + source: "default", + }; } private async setSelectedEndpointId(conversation: ConversationTarget, endpointId: string): Promise { @@ -1752,12 +1812,29 @@ export class CodexPluginController { }); } + private async clearSelectedEndpointId(conversation: ConversationTarget): Promise { + await this.store.removeConversationEndpoint(conversation); + } + + private formatEndpointResolutionLabel(selection: EndpointResolution): string { + if (selection.source === "manual") { + return `${selection.endpointId} (manual override)`; + } + if (selection.source === "auto-node") { + return `${selection.endpointId} (auto from node${selection.nodeId ? `: ${selection.nodeId}` : ""})`; + } + return `${selection.endpointId} (default)`; + } + private formatEndpointListText(params: { - selectedEndpointId: string; + conversation?: ConversationTarget | null; + selection: EndpointResolution; binding?: StoredBinding | null; }): string { + const manualEndpointId = this.getManualEndpointId(params.conversation ?? params.binding?.conversation ?? null); const lines = [ - `Selected endpoint: ${params.selectedEndpointId}`, + `Active endpoint: ${this.formatEndpointResolutionLabel(params.selection)}`, + `Manual override: ${manualEndpointId ?? "none"}`, params.binding ? `Bound endpoint: ${this.getEndpointIdForBinding(params.binding)}` : "Bound endpoint: none", @@ -1765,7 +1842,8 @@ export class CodexPluginController { "Configured endpoints:", ...this.settings.endpoints.map((endpoint) => { const markers = [ - endpoint.id === params.selectedEndpointId ? "selected" : "", + endpoint.id === params.selection.endpointId ? "active" : "", + endpoint.id === manualEndpointId ? "manual" : "", params.binding && endpoint.id === this.getEndpointIdForBinding(params.binding) ? "bound" : "", endpoint.id === this.settings.defaultEndpoint ? "default" : "", ].filter(Boolean); @@ -1774,28 +1852,32 @@ export class CodexPluginController { ]; if ( params.binding && - this.getEndpointIdForBinding(params.binding) !== params.selectedEndpointId + this.getEndpointIdForBinding(params.binding) !== params.selection.endpointId ) { lines.push( "", - "Note: this conversation is still bound to a thread on a different endpoint. Use /cas_resume after detaching if you want to bind on the selected endpoint.", + "Note: this conversation is still bound to a thread on a different endpoint. Use /cas_resume after detaching if you want to bind on the active endpoint.", ); } return lines.join("\n"); } private buildEndpointSelectionNotice( - endpointId: string, + selection: EndpointResolution, binding?: StoredBinding | null, + conversation?: ConversationTarget | null, ): string { return [ - `Selected endpoint set to ${endpointId}.`, - binding && this.getEndpointIdForBinding(binding) !== endpointId - ? `This conversation is still bound to a thread on ${this.getEndpointIdForBinding(binding)}. Use /cas_resume to browse/bind on ${endpointId}.` + selection.source === "manual" + ? `Manual endpoint override set to ${selection.endpointId}.` + : `Manual endpoint override cleared. Active endpoint is now ${this.formatEndpointResolutionLabel(selection)}.`, + binding && this.getEndpointIdForBinding(binding) !== selection.endpointId + ? `This conversation is still bound to a thread on ${this.getEndpointIdForBinding(binding)}. Use /cas_resume to browse/bind on ${selection.endpointId}.` : "", "", this.formatEndpointListText({ - selectedEndpointId: endpointId, + conversation, + selection, binding, }), ].filter(Boolean).join("\n"); @@ -2365,6 +2447,7 @@ export class CodexPluginController { return await this.handleFastCommand(binding, args); case "cas_model": return await this.handleModelCommand(conversation, binding, args); + case "cas_endpoints": case "cas_endpoint": return await this.handleEndpointCommand(conversation, binding, args); case "cas_permissions": @@ -3198,8 +3281,23 @@ export class CodexPluginController { statusMessage?: InteractiveMessageRef; }, ): Promise { - const selectedEndpointId = this.getSelectedEndpointId(conversation, binding); + const selection = this.getSelectedEndpointResolution(conversation); + const manualEndpointId = this.getManualEndpointId(conversation); const buttons: PluginInteractiveButtons = []; + if (manualEndpointId) { + const clearCallback = await this.store.putCallback({ + kind: "clear-endpoint", + conversation, + returnToStatus: opts?.returnToStatus, + statusMessage: opts?.statusMessage, + }); + buttons.push([ + { + text: "Use auto/default", + callback_data: `${INTERACTIVE_NAMESPACE}:${clearCallback.token}`, + }, + ]); + } for (const endpoint of this.settings.endpoints) { const endpointId = endpoint.id ?? this.settings.defaultEndpoint; const callback = await this.store.putCallback({ @@ -3210,7 +3308,8 @@ export class CodexPluginController { statusMessage: opts?.statusMessage, }); const flags = [ - endpointId === selectedEndpointId ? "selected" : "", + endpointId === selection.endpointId ? "active" : "", + endpointId === manualEndpointId ? "manual" : "", binding && endpointId === this.getEndpointIdForBinding(binding) ? "bound" : "", endpointId === this.settings.defaultEndpoint ? "default" : "", ].filter(Boolean); @@ -3235,7 +3334,8 @@ export class CodexPluginController { } return { text: this.formatEndpointListText({ - selectedEndpointId, + conversation, + selection, binding, }), buttons, @@ -3843,12 +3943,17 @@ export class CodexPluginController { if (parsed.error) { return { text: parsed.error }; } - const currentSelected = this.getSelectedEndpointId(conversation, binding); + const currentSelection = this.getSelectedEndpointResolution(conversation); if (!parsed.endpointId) { const picker = await this.buildEndpointPicker(conversation, binding); return buildReplyWithButtons(picker.text, picker.buttons); } const requested = parsed.endpointId.trim(); + if (["auto", "clear"].includes(requested.toLowerCase())) { + await this.clearSelectedEndpointId(conversation); + const nextSelection = this.getSelectedEndpointResolution(conversation); + return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; + } const endpoint = this.settings.endpoints.find((entry) => entry.id === requested); if (!endpoint) { return { @@ -3856,24 +3961,16 @@ export class CodexPluginController { `Unknown endpoint: ${requested}`, "", this.formatEndpointListText({ - selectedEndpointId: currentSelected, + conversation, + selection: currentSelection, binding, }), ].join("\n"), }; } await this.setSelectedEndpointId(conversation, endpoint.id || requested); - const nextSelected = endpoint.id || requested; - const lines = [ - `Selected endpoint set to ${nextSelected}.`, - ]; - if (binding && this.getEndpointIdForBinding(binding) !== nextSelected) { - lines.push( - `This conversation is still bound to a thread on ${this.getEndpointIdForBinding(binding)}. Use /cas_resume to browse/bind on ${nextSelected}.`, - ); - } - lines.push("", this.formatEndpointListText({ selectedEndpointId: nextSelected, binding })); - return { text: lines.join("\n") }; + const nextSelection = this.getSelectedEndpointResolution(conversation); + return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; } private async handlePermissionsCommand( @@ -6485,7 +6582,14 @@ export class CodexPluginController { binding, true, ) - : Promise.resolve({ text: this.formatEndpointListText({ selectedEndpointId: this.getSelectedEndpointId(conversation, binding), binding }), buttons: undefined }), + : Promise.resolve({ + text: this.formatEndpointListText({ + conversation, + selection: this.getSelectedEndpointResolution(conversation), + binding, + }), + buttons: undefined, + }), ]); await responders.editPicker({ text: statusCard.text, @@ -6558,7 +6662,11 @@ export class CodexPluginController { }; await this.setSelectedEndpointId(conversation, callback.endpointId); const refreshedBinding = this.store.getBinding(callback.conversation); - const text = this.buildEndpointSelectionNotice(callback.endpointId, refreshedBinding); + const text = this.buildEndpointSelectionNotice( + this.getSelectedEndpointResolution(conversation), + refreshedBinding, + conversation, + ); if (callback.returnToStatus && refreshedBinding) { const statusCard = await this.buildStatusCard( conversation, @@ -6595,6 +6703,50 @@ export class CodexPluginController { } return; } + if (callback.kind === "clear-endpoint") { + const binding = this.store.getBinding(callback.conversation); + await this.store.removeCallback(callback.token); + const conversation = { + ...callback.conversation, + threadId: responders.conversation.threadId, + }; + await this.clearSelectedEndpointId(conversation); + const refreshedBinding = this.store.getBinding(callback.conversation); + const text = this.buildEndpointSelectionNotice( + this.getSelectedEndpointResolution(conversation), + refreshedBinding, + conversation, + ); + if (callback.returnToStatus && refreshedBinding) { + const statusCard = await this.buildStatusCard( + conversation, + refreshedBinding, + true, + ); + if (responders.sourceMessage) { + await responders.editPicker({ + text: statusCard.text, + buttons: statusCard.buttons, + }); + await this.sendText(conversation, text); + } else { + await responders.acknowledge?.(); + await this.sendText(conversation, statusCard.text, { buttons: statusCard.buttons }); + await this.sendText(conversation, text); + } + return; + } + if (responders.sourceMessage) { + await responders.editPicker({ + text, + buttons: undefined, + }); + } else { + await responders.acknowledge?.(); + await this.sendText(conversation, text); + } + return; + } if (callback.kind === "set-model") { const binding = this.store.getBinding(callback.conversation); await this.store.removeCallback(callback.token); @@ -7275,7 +7427,8 @@ export class CodexPluginController { binding: StoredBinding | null, bindingActive: boolean, ): Promise { - const selectedEndpointId = this.getSelectedEndpointId(conversation, binding); + const selection = this.getSelectedEndpointResolution(conversation); + const selectedEndpointId = selection.endpointId; const activeRun = bindingActive && conversation ? this.activeRuns.get(buildConversationKey(conversation)) @@ -7322,7 +7475,7 @@ export class CodexPluginController { : undefined; const endpointNote = binding && this.getEndpointIdForBinding(binding) !== selectedEndpointId - ? `Selected endpoint ${selectedEndpointId} differs from the bound endpoint ${this.getEndpointIdForBinding(binding)}.` + ? `Active endpoint ${selectedEndpointId} differs from the bound endpoint ${this.getEndpointIdForBinding(binding)}.` : undefined; this.api.logger.debug?.( `codex status snapshot bindingActive=${bindingActive ? "yes" : "no"} activeRun=${activeRun?.mode ?? "none"} boundThread=${binding?.threadId ?? ""} raw=${formatThreadStateForLog(threadState)} effective=${formatThreadStateForLog(displayThreadState)} ${formatBindingPreferencesForLog(binding)} threadCwd=${displayThreadState?.cwd?.trim() || ""}`, @@ -7331,6 +7484,7 @@ export class CodexPluginController { return formatCodexStatusText({ pluginVersion: PLUGIN_VERSION, endpointId: selectedEndpointId, + endpointLabel: this.formatEndpointResolutionLabel(selection), threadState: displayThreadState, bindingThreadTitle: binding?.threadTitle, account, diff --git a/src/format.ts b/src/format.ts index 6f36314..0bdde8e 100644 --- a/src/format.ts +++ b/src/format.ts @@ -526,6 +526,7 @@ export function formatCodexContextUsageSnapshot( export function formatCodexStatusText(params: { pluginVersion?: string; endpointId?: string; + endpointLabel?: string; threadState?: ThreadState; bindingThreadTitle?: string; account?: AccountSummary | null; @@ -551,8 +552,9 @@ export function formatCodexStatusText(params: { if (params.pluginVersion?.trim()) { lines.push(`Plugin version: ${params.pluginVersion.trim()}`); } - if (params.endpointId?.trim()) { - lines.push(`Endpoint: ${params.endpointId.trim()}`); + const endpointLabel = params.endpointLabel?.trim() || params.endpointId?.trim(); + if (endpointLabel) { + lines.push(`Endpoint: ${endpointLabel}`); } if (params.threadState) { lines.push(`Model: ${formatCodexModelText(params.threadState)}`); diff --git a/src/help.ts b/src/help.ts index dc9411f..4e9c1f2 100644 --- a/src/help.ts +++ b/src/help.ts @@ -147,16 +147,23 @@ export const COMMAND_HELP: Record = { ], notes: "The status card is the main interactive model-control surface, but this command remains available.", }, + cas_endpoints: { + summary: COMMAND_SUMMARY.cas_endpoints, + usage: "/cas_endpoints", + examples: ["/cas_endpoints"], + notes: "Shows configured endpoints, the active resolution source for this conversation, and whether a manual endpoint override is currently set.", + }, cas_endpoint: { summary: COMMAND_SUMMARY.cas_endpoint, - usage: "/cas_endpoint [endpoint_id]", - flags: [{ flag: "[endpoint_id]", description: "Show the active endpoint or switch this conversation to a configured endpoint id." }], + usage: "/cas_endpoint [endpoint_id|auto|clear]", + flags: [{ flag: "[endpoint_id|auto|clear]", description: "Show the active endpoint, switch this conversation to a configured endpoint id, or clear the manual override and return to automatic resolution." }], examples: [ "/cas_endpoint", "/cas_endpoint primary", "/cas_endpoint backup", + "/cas_endpoint auto", ], - notes: "Changing the selected endpoint affects future /cas_resume and unbound CAS actions. Existing bindings stay attached to their original endpoint until you resume/bind there again.", + notes: "Manual endpoint selection affects future /cas_resume and unbound CAS actions for this conversation. Existing bindings stay attached to their original endpoint until you resume/bind there again. Use `auto` or `clear` to remove the manual override.", }, cas_permissions: { summary: COMMAND_SUMMARY.cas_permissions, diff --git a/src/state.ts b/src/state.ts index ff0d0c7..b154f6b 100644 --- a/src/state.ts +++ b/src/state.ts @@ -194,6 +194,14 @@ type PutCallbackInput = token?: string; ttlMs?: number; } + | { + kind: "clear-endpoint"; + conversation: ConversationTarget; + returnToStatus?: boolean; + statusMessage?: Extract["statusMessage"]; + token?: string; + ttlMs?: number; + } | { kind: "reply-text"; conversation: ConversationTarget; @@ -403,6 +411,14 @@ export class PluginStateStore { await this.save(); } + async removeConversationEndpoint(target: ConversationTarget): Promise { + const key = toConversationKey(target); + this.snapshot.conversationEndpoints = this.snapshot.conversationEndpoints.filter( + (current) => toConversationKey(current.conversation as ConversationTarget) !== key, + ); + await this.save(); + } + async upsertBinding(binding: StoredBinding): Promise { const key = toConversationKey(binding.conversation); this.snapshot.bindings = this.snapshot.bindings.filter( @@ -721,6 +737,16 @@ export class PluginStateStore { createdAt: now, expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS), } + : callback.kind === "clear-endpoint" + ? { + kind: "clear-endpoint", + conversation: callback.conversation, + returnToStatus: callback.returnToStatus, + statusMessage: callback.statusMessage, + token: callback.token ?? this.createCallbackToken(), + createdAt: now, + expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS), + } : callback.kind === "reply-text" ? { kind: "reply-text", diff --git a/src/types.ts b/src/types.ts index 47ff744..8bbf2e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -573,6 +573,15 @@ export type CallbackAction = createdAt: number; expiresAt: number; } + | { + token: string; + kind: "clear-endpoint"; + conversation: ConversationRef; + returnToStatus?: boolean; + statusMessage?: InteractiveMessageRef; + createdAt: number; + expiresAt: number; + } | { token: string; kind: "reply-text"; From 2870af78f0c5fb53ba8c3aba16720bbf6faea2e1 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 23 Apr 2026 10:27:06 +0000 Subject: [PATCH 05/19] feat: derive node websocket endpoint fallback for CAS workers --- src/controller.test.ts | 72 ++++++++++++++++++++++ src/controller.ts | 131 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 62e0b84..0c0fd49 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -6879,6 +6879,78 @@ describe("Discord controller flows", () => { expect((controller as any).resolveAgentEndpointId(undefined, { host: "gateway", node: "nestdev" })).toBe("default"); }); + it("falls back to a derived node endpoint when exec host=node has no configured match", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + ], + }); + const deriveSpy = vi + .spyOn(controller as any, "tryRegisterNodeDerivedEndpoint") + .mockResolvedValue("auto-node-nestdev"); + + await expect( + (controller as any).resolveAgentEndpointIdWithNodeFallback(undefined, { + host: "node", + node: "nestdev", + }), + ).resolves.toBe("auto-node-nestdev"); + expect(deriveSpy).toHaveBeenCalledWith({ host: "node", node: "nestdev" }); + }); + + it("falls back to default endpoint when node-derived probe is unavailable", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + ], + }); + vi.spyOn(controller as any, "tryRegisterNodeDerivedEndpoint").mockResolvedValue(undefined); + + await expect( + (controller as any).resolveAgentEndpointIdWithNodeFallback(undefined, { + host: "node", + node: "nestdev", + }), + ).resolves.toBe("default"); + }); + + it("keeps explicit endpoint selection over node-derived fallback", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "gateway", + transport: "websocket", + url: "ws://127.0.0.1:9999", + }, + ], + }); + const deriveSpy = vi.spyOn(controller as any, "tryRegisterNodeDerivedEndpoint"); + + await expect( + (controller as any).resolveAgentEndpointIdWithNodeFallback("gateway", { + host: "node", + node: "nestdev", + }), + ).resolves.toBe("gateway"); + expect(deriveSpy).not.toHaveBeenCalled(); + }); + it("prefers a manual conversation endpoint over automatic node resolution", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", diff --git a/src/controller.ts b/src/controller.ts index ea68082..33ad650 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -55,6 +55,7 @@ import type { CollaborationMode, CodexTurnInputItem, ConversationPreferences, + EndpointSettings, InteractiveMessageRef, PendingInputState, PermissionsMode, @@ -87,6 +88,7 @@ import { paginateItems, } from "./thread-picker.js"; import { + DEFAULT_REQUEST_TIMEOUT_MS, INTERACTIVE_NAMESPACE, PLUGIN_ID, type CallbackAction, @@ -1488,7 +1490,10 @@ export class CodexPluginController { threads: Awaited>; }> { await this.start(); - const endpointId = this.resolveAgentEndpointId(params.endpointId, params.execContext); + const endpointId = await this.resolveAgentEndpointIdWithNodeFallback( + params.endpointId, + params.execContext, + ); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = params.includeAllWorkspaces ? undefined @@ -1526,7 +1531,10 @@ export class CodexPluginController { context: Awaited>; }> { await this.start(); - const endpointId = this.resolveAgentEndpointId(params.endpointId, params.execContext); + const endpointId = await this.resolveAgentEndpointIdWithNodeFallback( + params.endpointId, + params.execContext, + ); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const threadId = params.threadId.trim(); const client = this.getClientForEndpoint(endpointId); @@ -1578,7 +1586,10 @@ export class CodexPluginController { result: TurnResult; }> { await this.start(); - const endpointId = this.resolveAgentEndpointId(params.endpointId, params.execContext); + const endpointId = await this.resolveAgentEndpointIdWithNodeFallback( + params.endpointId, + params.execContext, + ); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = resolveWorkspaceDir({ requested: params.workspaceDir, @@ -1693,6 +1704,120 @@ export class CodexPluginController { return this.settings.defaultEndpoint; } + private async resolveAgentEndpointIdWithNodeFallback( + endpointId?: string, + execContext?: AgentExecContext, + ): Promise { + const requested = endpointId?.trim(); + if (requested) { + return this.resolveAgentEndpointId(requested, execContext); + } + const inferred = this.resolveEndpointIdFromExecContext(execContext); + if (inferred) { + return inferred; + } + const derived = await this.tryRegisterNodeDerivedEndpoint(execContext); + if (derived) { + return derived; + } + return this.settings.defaultEndpoint; + } + + private async tryRegisterNodeDerivedEndpoint( + execContext?: AgentExecContext, + ): Promise { + const host = execContext?.host?.trim().toLowerCase(); + const node = execContext?.node?.trim(); + if (host !== "node" || !node) { + return undefined; + } + const normalizedNode = node.toLowerCase(); + const existingAliasMatch = this.settings.endpoints.find((entry) => + (entry.execNodes ?? []).some((alias) => alias.trim().toLowerCase() === normalizedNode), + ); + if (existingAliasMatch?.id) { + return existingAliasMatch.id; + } + const derivedEndpointId = this.buildNodeDerivedEndpointId(node); + const existingById = this.settings.endpoints.find((entry) => entry.id === derivedEndpointId); + if (existingById?.id) { + return existingById.id; + } + + const derivedUrl = this.buildNodeDerivedEndpointUrl(node); + const probeEndpoint: EndpointSettings = { + id: `${derivedEndpointId}__probe`, + execNodes: [node], + transport: "websocket", + command: "codex", + args: [], + url: derivedUrl, + requestTimeoutMs: 3_000, + }; + const probeClient = new CodexAppServerModeClient(probeEndpoint, this.api.logger); + let available = false; + try { + await probeClient.readAccount({ profile: "default" }); + available = true; + } catch (error) { + this.api.logger.debug?.( + `codex auto-node endpoint probe failed node=${node} url=${derivedUrl}: ${String(error)}`, + ); + } finally { + await probeClient.close().catch(() => undefined); + } + if (!available) { + return undefined; + } + + const derivedEndpoint: EndpointSettings = { + id: derivedEndpointId, + execNodes: [node], + transport: "websocket", + command: "codex", + args: [], + url: derivedUrl, + requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS, + }; + this.settings.endpoints.push(derivedEndpoint); + this.api.logger.info( + `codex auto-node endpoint registered id=${derivedEndpoint.id} node=${node} url=${derivedUrl}`, + ); + return derivedEndpoint.id; + } + + private buildNodeDerivedEndpointId(node: string): string { + const normalized = node + .trim() + .toLowerCase() + .replace(/[^a-z0-9.-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `auto-node-${normalized || "default"}`; + } + + private buildNodeDerivedEndpointUrl(node: string): string { + const trimmed = node.trim(); + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed); + parsed.protocol = "ws:"; + if (!parsed.port) { + parsed.port = "8765"; + } + return parsed.toString().replace(/\/$/, ""); + } catch { + // fall through and try host-based parsing + } + } + if (/^\[[^\]]+\](?::\d+)?$/.test(trimmed)) { + return /:\d+$/.test(trimmed) ? `ws://${trimmed}` : `ws://${trimmed}:8765`; + } + if (/^[^:]+:\d+$/.test(trimmed)) { + return `ws://${trimmed}`; + } + return `ws://${trimmed}:8765`; + } + private resolveEndpointIdFromExecContext(execContext?: AgentExecContext): string | undefined { const host = execContext?.host?.trim().toLowerCase(); if (host !== "node") { From 73db523019c8e25563ef88935b46621ddcd84153 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 23 Apr 2026 10:57:24 +0000 Subject: [PATCH 06/19] feat: always include resolved endpoint in cas_resume replies --- src/controller.test.ts | 50 ++++++++++++++++++++++++------------------ src/controller.ts | 40 ++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 0c0fd49..160b1fe 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -614,9 +614,8 @@ describe("Discord controller flows", () => { const reply = await controller.handleCommand("cas_resume", buildDiscordCommandContext()); - expect(reply).toEqual({ - text: "Sent a Codex thread picker to this Discord conversation.", - }); + expect(reply.text).toContain("Sent a Codex thread picker to this Discord conversation."); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(sendComponentMessage).toHaveBeenCalledWith( "channel:chan-1", expect.objectContaining({ @@ -633,9 +632,8 @@ describe("Discord controller flows", () => { const reply = await controller.handleCommand("cas_resume", buildDiscordCommandContext()); - expect(reply).toEqual({ - text: "Sent a Codex thread picker to this Discord conversation.", - }); + expect(reply.text).toContain("Sent a Codex thread picker to this Discord conversation."); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(discordOutbound.sendPayload).toHaveBeenCalledWith( expect.objectContaining({ to: "channel:chan-1", @@ -666,9 +664,8 @@ describe("Discord controller flows", () => { const reply = await controller.handleCommand("cas_resume", buildDiscordCommandContext()); - expect(reply).toEqual({ - text: "Sent a Codex thread picker to this Discord conversation.", - }); + expect(reply.text).toContain("Sent a Codex thread picker to this Discord conversation."); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(sendDiscordComponentMessage).toHaveBeenCalledWith( "channel:chan-1", expect.objectContaining({ @@ -681,6 +678,16 @@ describe("Discord controller flows", () => { ); }); + it("includes the resolved endpoint in cas_resume replies when the command fails", async () => { + const { controller } = await createControllerHarness(); + vi.spyOn(controller as any, "handleJoinCommand").mockRejectedValue(new Error("boom")); + + const reply = await controller.handleCommand("cas_resume", buildDiscordCommandContext()); + + expect(reply.text).toContain("cas_resume failed: boom"); + expect(reply.text).toContain("Resolved endpoint: default (default)"); + }); + it("renders structured help text for representative commands via handleCommand", async () => { const { controller } = await createControllerHarness(); @@ -951,7 +958,7 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(clientMock.startThread).toHaveBeenCalledWith({ profile: "default", sessionKey: undefined, @@ -1054,7 +1061,7 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(clientMock.startThread).toHaveBeenCalledWith({ profile: "default", sessionKey: undefined, @@ -1107,7 +1114,7 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); const binding = (controller as any).store.getBinding({ channel: "discord", accountId: "default", @@ -1129,7 +1136,7 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); const binding = (controller as any).store.getBinding({ channel: "discord", accountId: "default", @@ -1190,9 +1197,8 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({ - text: "Sent a Codex thread picker to this Discord conversation.", - }); + expect(reply.text).toContain("Sent a Codex thread picker to this Discord conversation."); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(sendComponentMessage).toHaveBeenCalledWith( "channel:chan-1", expect.objectContaining({ @@ -2664,7 +2670,7 @@ describe("Discord controller flows", () => { "Discord Thread (openclaw)", expect.objectContaining({ accountId: "default" }), ); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); const lastCall = sendMessageTelegram.mock.calls.at(-1) as unknown as | [string, string, { buttons?: Array>; messageThreadId?: number }] | undefined; @@ -2864,7 +2870,8 @@ describe("Discord controller flows", () => { }), ); - expect(pendingReply).toEqual({ text: "Plugin bind approval required" }); + expect(pendingReply.text).toContain("Plugin bind approval required"); + expect(pendingReply.text).toContain("Resolved endpoint: default (default)"); expect((controller as any).store.getPendingBind({ channel: "telegram", accountId: "default", @@ -2896,7 +2903,7 @@ describe("Discord controller flows", () => { "Discord Thread (openclaw)", expect.objectContaining({ accountId: "default" }), ); - expect(hydratedReply).toEqual({}); + expect(hydratedReply.text).toContain("Resolved endpoint: default (default)"); const hydratedLastCall = sendMessageTelegram.mock.calls.at(-1) as unknown as | [string, string, { buttons?: Array>; messageThreadId?: number }] | undefined; @@ -2950,7 +2957,8 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({ text: "Plugin bind approval required" }); + expect(reply.text).toContain("Plugin bind approval required"); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(requestConversationBinding).toHaveBeenCalledWith( expect.objectContaining({ summary: "Bind this conversation to Codex thread Discord Thread.", @@ -2993,7 +3001,7 @@ describe("Discord controller flows", () => { await flushAsyncWork(); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(renameTopic).toHaveBeenCalledWith( "123", 456, diff --git a/src/controller.ts b/src/controller.ts index 33ad650..35e737c 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2524,16 +2524,36 @@ export class CodexPluginController { } switch (commandName) { - case "cas_resume": - return await this.handleJoinCommand( - conversation, - binding, - args, - ctx.channel, - ctx, - pendingBind, - hydratedBinding?.pendingBind, - ); + case "cas_resume": { + const resolvedEndpointText = conversation + ? `Resolved endpoint: ${this.formatEndpointResolutionLabel(this.getSelectedEndpointResolution(conversation))}` + : undefined; + const withResolvedEndpoint = (reply: ReplyPayload): ReplyPayload => { + if (!resolvedEndpointText) { + return reply; + } + const text = reply.text?.trim(); + return { + ...reply, + text: text ? `${text}\n\n${resolvedEndpointText}` : resolvedEndpointText, + }; + }; + try { + const reply = await this.handleJoinCommand( + conversation, + binding, + args, + ctx.channel, + ctx, + pendingBind, + hydratedBinding?.pendingBind, + ); + return withResolvedEndpoint(reply); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return withResolvedEndpoint({ text: `cas_resume failed: ${message}` }); + } + } case "cas_detach": if (!conversation) { return { text: "This command needs a Telegram or Discord conversation." }; From 18180ecdd11bfc8504f525424d7ce1c64229ce18 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Fri, 17 Apr 2026 20:21:51 +0000 Subject: [PATCH 07/19] Plugin: transcribe inbound audio before Codex turns --- CHANGELOG.md | 10 ++ README.md | 33 +++++++ docs/specs/MEDIA.md | 40 +++++++- openclaw.plugin.json | 27 ++++++ src/config.ts | 24 ++++- src/controller.test.ts | 214 +++++++++++++++++++++++++++++++++++++++++ src/controller.ts | 129 ++++++++++++++++++++++++- src/types.ts | 8 ++ 8 files changed, 479 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee2a1c..b414661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Highlights + +- Added an optional inbound audio transcription preprocessor so bound conversations can convert staged voice/audio attachments into normal text turn input before forwarding the turn into Codex. The plugin stays transport-agnostic by delegating transcription to a configurable local command that prints transcript text to stdout. + +### Docs + +- Documented the new `inboundAudioTranscription` plugin config and clarified the media bridge notes around staged inbound audio handling. + ## v0.6.0 - 2026-04-03 ### Highlights diff --git a/README.md b/README.md index e6fd111..54a7db2 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,39 @@ The plugin schema in [`openclaw.plugin.json`](./openclaw.plugin.json) supports: - `defaultWorkspaceDir`: fallback workspace for unbound actions - `defaultModel`: model used when a new thread starts without an explicit selection - `defaultServiceTier`: default service tier for new turns +- `inboundAudioTranscription`: optional preprocessor for inbound audio/voice attachments before they are forwarded into Codex + +### Optional inbound audio transcription + +If your chat surface provides inbound audio files as local paths or media metadata, this plugin can transcribe them before forwarding the turn to Codex. This keeps the plugin transport-agnostic: Codex still receives normal text input, while transcription is delegated to any local command you choose. + +Example config using an existing local script: + +```json +{ + "inboundAudioTranscription": { + "enabled": true, + "command": "/root/.openclaw/workspace/scripts/local-stt-transcribe.sh", + "args": ["{path}"], + "timeoutMs": 20000 + } +} +``` + +Behavior: + +- audio-only inbound messages become transcript text +- caption + audio keeps the caption and adds a labeled transcript block +- the command should print the transcript to stdout +- if stdout is JSON, `.text` or `.transcript` is used automatically + +Argument placeholders supported in `args`: + +- `{path}` +- `{mimeType}` +- `{fileName}` + +If `{path}` is omitted from `args`, the plugin appends the media path automatically. ## Developer Workflow With A Local OpenClaw Checkout diff --git a/docs/specs/MEDIA.md b/docs/specs/MEDIA.md index 4300594..ed33ad8 100644 --- a/docs/specs/MEDIA.md +++ b/docs/specs/MEDIA.md @@ -5,7 +5,8 @@ This document captures the current state of media handling relevant to this plug - how Codex app-server accepts image input - what this plugin currently sends - what OpenClaw currently exposes to plugins -- the gap for inbound media +- the remaining gap for richer inbound media +- the staged-audio transcription bridge this plugin now supports - a recommended bridge design for future implementation This is a spec/notes document only. It does not imply that inbound media support has already been implemented here. @@ -15,9 +16,11 @@ This is a spec/notes document only. It does not imply that inbound media support - Codex app-server already supports multimodal turn input via `UserInput`. - The supported image-shaped input items are remote/data URL images and local filesystem images. - This plugin now supports mixed text + image turn input and forwards inbound image media into Codex when OpenClaw provides a staged media path or URL. +- This plugin can also transcribe staged inbound audio/voice attachments into plain text turn input when a local transcription command is configured. - OpenClaw’s plugin SDK already supports outbound attachments from a plugin via `mediaUrl` and `mediaUrls`. - OpenClaw’s plugin SDK still does not model inbound attachments as a first-class typed field on command or `inbound_claim` events. - In practice, current `inbound_claim` hook metadata already carries `mediaPath` / `mediaType`, which is enough for this plugin to forward a staged inbound image. +- The same staged inbound path is also enough to transcribe audio before Codex sees the turn, as long as the plugin can execute an external transcription command against the staged file. - The cleanest future bridge is: OpenClaw stages inbound files locally, then this plugin maps image paths to Codex `localImage` items. ## Codex App-Server Input Model @@ -177,8 +180,41 @@ That means: - text-only turns still work as before - mixed text + image turns can be forwarded into Codex - image-only inbound turns can be forwarded into Codex +- audio-only inbound turns can be converted into transcript text before the turn starts when `inboundAudioTranscription` is configured +- mixed caption + audio inbound turns can keep the original text and append a labeled transcript block - staged text attachments such as `.txt`, `.md`, `.json`, `.yaml`, and `.yml` can be read and forwarded as additional `text` items -- unsupported binary non-image inbound media is still ignored for now +- unsupported binary non-image inbound media is still ignored for now unless a future bridge teaches the plugin how to reinterpret it + +## Inbound Audio Transcription Bridge + +The plugin does not send raw audio into Codex. Instead, it can optionally reinterpret staged audio files as text by invoking a configurable local command. + +Configuration shape: + +```json +{ + "inboundAudioTranscription": { + "enabled": true, + "command": "/path/to/transcribe", + "args": ["{path}"], + "timeoutMs": 20000 + } +} +``` + +Behavior: + +- The command receives the staged media path either through an explicit `{path}` placeholder or as an appended trailing argument. +- Optional placeholders `{mimeType}` and `{fileName}` are available for wrappers that need them. +- The command should print the transcript to stdout. +- If stdout is JSON, the plugin uses `.text` first and then `.transcript`. +- On transcription failure or timeout, the plugin logs the failure and falls back to the previous behavior instead of crashing the inbound turn. + +This keeps the bridge generic: + +- no hard dependency on a specific speech-to-text engine +- no plugin-side audio decoding logic +- no transport-specific behavior baked into the Codex turn layer ## OpenClaw Plugin SDK: Outbound Media diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 88875f3..b0514b4 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -112,6 +112,28 @@ }, "defaultServiceTier": { "type": "string" + }, + "inboundAudioTranscription": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeoutMs": { + "type": "number", + "minimum": 100 + } + } } } }, @@ -171,6 +193,11 @@ "defaultServiceTier": { "label": "Default Service Tier", "advanced": true + }, + "inboundAudioTranscription": { + "label": "Inbound Audio Transcription", + "advanced": true, + "help": "Optional preprocessor for inbound audio/voice attachments. The command should print the transcript to stdout. Use {path}, {mimeType}, and {fileName} placeholders in args when needed." } } } diff --git a/src/config.ts b/src/config.ts index f1d2e2e..9f0b3e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,8 @@ -import type { EndpointSettings, PluginSettings } from "./types.js"; +import type { + EndpointSettings, + InboundAudioTranscriptionSettings, + PluginSettings, +} from "./types.js"; import { DEFAULT_REQUEST_TIMEOUT_MS, } from "./types.js"; @@ -64,6 +68,23 @@ function readNumber( return fallback; } +function resolveInboundAudioTranscription( + record: Record, +): InboundAudioTranscriptionSettings | undefined { + const nested = asRecord(record.inboundAudioTranscription); + const legacy = asRecord(record.audioTranscription); + const source = Object.keys(nested).length > 0 ? nested : legacy; + if (Object.keys(source).length === 0) { + return undefined; + } + return { + enabled: source.enabled !== false, + command: readString(source, "command"), + args: readStringArray(source, "args"), + timeoutMs: readNumber(source, "timeoutMs", 20_000, 100), + }; +} + export function resolvePluginSettings(rawConfig: unknown): PluginSettings { const record = asRecord(rawConfig); const endpointRecords = Array.isArray(record.endpoints) @@ -134,6 +155,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"), defaultModel: readString(record, "defaultModel"), defaultServiceTier: readString(record, "defaultServiceTier"), + inboundAudioTranscription: resolveInboundAudioTranscription(record), }; } diff --git a/src/controller.test.ts b/src/controller.test.ts index 160b1fe..8154990 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -313,6 +313,99 @@ async function createControllerHarness(pluginConfigOverrides: Record) { + const { + api, + sendComponentMessage, + sendMessageDiscord, + sendMessageTelegram, + discordTypingStart, + renameTopic, + resolveTelegramToken, + editChannel, + discordOutbound, + stateDir, + } = createApiMock(pluginConfigOverrides); + const controller = new CodexPluginController(api); + await controller.start(); + const threadState: any = { + threadId: "thread-1", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "on-request", + sandbox: "workspace-write", + }; + const clientMock = { + hasProfile: vi.fn((profile: string) => profile === "default" || profile === "full-access"), + listThreads: vi.fn(async () => []), + startThread: vi.fn(async () => ({ + threadId: "thread-new", + threadName: "New Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + })), + listModels: vi.fn(async () => [{ id: "openai/gpt-5.4", current: true }]), + listSkills: vi.fn(async () => []), + listMcpServers: vi.fn(async () => []), + readThreadState: vi.fn(async () => ({ ...threadState })), + readThreadContext: vi.fn(async () => ({ + lastUserMessage: undefined, + lastAssistantMessage: undefined, + })), + setThreadName: vi.fn(async () => ({ + threadId: "thread-1", + threadName: "Discord Thread", + })), + setThreadModel: vi.fn(async (params: { model: string }) => { + threadState.model = params.model; + return { ...threadState }; + }), + setThreadServiceTier: vi.fn(async (params: { serviceTier: string | null }) => { + threadState.serviceTier = params.serviceTier ?? "default"; + return { ...threadState }; + }), + setThreadPermissions: vi.fn(async (params: { approvalPolicy: string; sandbox: string }) => { + threadState.approvalPolicy = params.approvalPolicy; + threadState.sandbox = params.sandbox; + return { ...threadState }; + }), + startReview: vi.fn(() => ({ + result: new Promise(() => {}), + getThreadId: () => "thread-1", + queueMessage: vi.fn(async () => false), + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + })), + readAccount: vi.fn(async () => ({ + email: "test@example.com", + planType: "pro", + type: "chatgpt", + })), + readRateLimits: vi.fn(async () => []), + }; + (controller as any).client = clientMock; + (controller as any).readThreadHasChanges = vi.fn(async () => false); + return { + controller, + api, + clientMock, + sendComponentMessage, + sendMessageDiscord, + sendMessageTelegram, + discordTypingStart, + renameTopic, + resolveTelegramToken, + editChannel, + discordOutbound, + stateDir, + }; +} + async function createControllerHarnessWithoutLegacyBindings() { const harness = createApiMock(); delete (harness.api as any).runtime.channel.bindings; @@ -4234,6 +4327,127 @@ describe("Discord controller flows", () => { ); }); + it("transcribes inbound audio with a configured command before starting the turn", async () => { + const { controller, stateDir } = await createControllerHarnessWithPluginConfig({ + inboundAudioTranscription: { + enabled: true, + command: process.execPath, + args: [ + "-e", + 'process.stdout.write(JSON.stringify({text:`Transcript for ${process.argv[1]}`}))', + "{path}", + ], + }, + }); + const audioPath = path.join(stateDir, "tmp", "voice.ogg"); + fs.mkdirSync(path.dirname(audioPath), { recursive: true }); + fs.writeFileSync(audioPath, "ogg"); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: TEST_TELEGRAM_PEER_ID, + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + const startTurn = vi.fn(() => ({ + result: Promise.resolve({ + threadId: "thread-1", + text: "handled", + }), + getThreadId: () => "thread-1", + queueMessage: vi.fn(async () => true), + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + })); + (controller as any).client.startTurn = startTurn; + + const result = await controller.handleInboundClaim({ + content: "", + channel: "telegram", + accountId: "default", + conversationId: TEST_TELEGRAM_PEER_ID, + isGroup: false, + metadata: { mediaPath: audioPath, mediaType: "audio/ogg" }, + }); + + expect(result).toEqual({ handled: true }); + expect(startTurn).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "", + input: [{ type: "text", text: `Transcript for ${audioPath}` }], + }), + ); + }); + + it("keeps labeled transcript text when audio arrives with a caption", async () => { + const { controller, stateDir } = await createControllerHarnessWithPluginConfig({ + inboundAudioTranscription: { + enabled: true, + command: process.execPath, + args: [ + "-e", + 'process.stdout.write("hello from audio")', + ], + }, + }); + const audioPath = path.join(stateDir, "tmp", "voice-note.ogg"); + fs.mkdirSync(path.dirname(audioPath), { recursive: true }); + fs.writeFileSync(audioPath, "ogg"); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "telegram", + accountId: "default", + conversationId: TEST_TELEGRAM_PEER_ID, + }, + sessionKey: "session-1", + threadId: "thread-1", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + const startTurn = vi.fn(() => ({ + result: Promise.resolve({ + threadId: "thread-1", + text: "handled", + }), + getThreadId: () => "thread-1", + queueMessage: vi.fn(async () => true), + interrupt: vi.fn(async () => {}), + isAwaitingInput: () => false, + submitPendingInput: vi.fn(async () => false), + submitPendingInputPayload: vi.fn(async () => false), + })); + (controller as any).client.startTurn = startTurn; + + const result = await controller.handleInboundClaim({ + content: "Please use this note", + channel: "telegram", + accountId: "default", + conversationId: TEST_TELEGRAM_PEER_ID, + isGroup: false, + metadata: { mediaPath: audioPath, mediaType: "audio/ogg" }, + }); + + expect(result).toEqual({ handled: true }); + expect(startTurn).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Please use this note", + input: [ + { type: "text", text: "Please use this note" }, + { + type: "text", + text: "Transcribed audio: voice-note.ogg\n\nhello from audio", + }, + ], + }), + ); + }); + it("forwards text file inbound media metadata as text turn input", async () => { const { controller, stateDir } = await createControllerHarness(); const filePath = path.join(stateDir, "tmp", "note.txt"); diff --git a/src/controller.ts b/src/controller.ts index 35e737c..6c3e88d 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -193,6 +193,16 @@ const TEXT_ATTACHMENT_MIME_TYPES = new Set([ "text/x-markdown", "text/yaml", ]); +const AUDIO_FILE_EXTENSIONS = new Set([ + ".aac", + ".flac", + ".m4a", + ".mp3", + ".ogg", + ".opus", + ".wav", + ".webm", +]); const MAX_TEXT_ATTACHMENT_BYTES = 64 * 1024; type TelegramOutboundAdapter = { @@ -665,6 +675,19 @@ function isImagePathLike(value: string | undefined): boolean { return IMAGE_FILE_EXTENSIONS.has(path.extname(normalized).toLowerCase()); } +function isAudioMimeType(value: string | undefined): boolean { + const normalized = normalizeMimeType(value); + return Boolean(normalized?.startsWith("audio/")); +} + +function isAudioPathLike(value: string | undefined): boolean { + const normalized = normalizeInboundMediaPath(value); + if (!normalized) { + return false; + } + return AUDIO_FILE_EXTENSIONS.has(path.extname(normalized).toLowerCase()); +} + function isTextAttachmentMimeType(value: string | undefined): boolean { const normalized = normalizeMimeType(value); return Boolean( @@ -793,18 +816,81 @@ async function toCodexTextAttachmentInputItem( return { type: "text", text: lines.join("\n") }; } +function extractTranscriptText(stdout: string): string { + const trimmed = stdout.trim(); + if (!trimmed) { + return ""; + } + try { + const parsed = JSON.parse(trimmed) as { text?: unknown; transcript?: unknown }; + const value = + typeof parsed?.text === "string" + ? parsed.text + : typeof parsed?.transcript === "string" + ? parsed.transcript + : undefined; + return value?.trim() ?? trimmed; + } catch { + return trimmed; + } +} + +function buildAudioTranscriptArgv(params: { + args: readonly string[]; + mediaPath: string; + mimeType?: string; + fileName?: string; +}): string[] { + const replacements = { + path: params.mediaPath, + mimeType: params.mimeType ?? "", + fileName: params.fileName ?? path.basename(params.mediaPath), + }; + const rendered = params.args.map((entry) => + entry.replace(/\{(path|mimeType|fileName)\}/g, (_match, key: keyof typeof replacements) => { + return replacements[key] ?? ""; + }), + ); + if (!rendered.some((entry) => entry.includes(params.mediaPath))) { + rendered.push(params.mediaPath); + } + return rendered; +} + async function buildInboundTurnInput(event: { content: string; media?: PluginInboundMedia[]; metadata?: Record; + transcribeAudio?: (media: PluginInboundMedia) => Promise; }): Promise { const items: CodexTurnInputItem[] = []; if (event.content.trim()) { items.push({ type: "text", text: event.content }); } + const normalizedMedia = [...(event.media ?? []), ...extractInboundMetadataMedia(event.metadata)]; + const onlyAudioWithoutPrompt = + !event.content.trim() && + normalizedMedia.length === 1 && + (isAudioMimeType(normalizedMedia[0]?.mimeType) || + isAudioPathLike(normalizedMedia[0]?.path) || + isAudioPathLike(normalizedMedia[0]?.url)); const seen = new Set(); - for (const media of [...(event.media ?? []), ...extractInboundMetadataMedia(event.metadata)]) { - const item = toCodexImageInputItem(media) ?? (await toCodexTextAttachmentInputItem(media)); + for (const media of normalizedMedia) { + let item: CodexTurnInputItem | null = null; + if (event.transcribeAudio && + (isAudioMimeType(media.mimeType) || isAudioPathLike(media.path) || isAudioPathLike(media.url))) { + const transcript = await event.transcribeAudio(media); + if (transcript?.trim()) { + const displayName = media.fileName?.trim() || path.basename(media.path ?? media.url ?? "audio"); + item = { + type: "text", + text: onlyAudioWithoutPrompt + ? transcript.trim() + : [`Transcribed audio: ${displayName}`, "", transcript.trim()].join("\n"), + }; + } + } + item ??= toCodexImageInputItem(media) ?? (await toCodexTextAttachmentInputItem(media)); if (!item) { continue; } @@ -2101,6 +2187,40 @@ export class CodexPluginController { ].join(" "); } + private async transcribeInboundAudio(media: PluginInboundMedia): Promise { + const settings = this.settings.inboundAudioTranscription; + if (!settings?.enabled || !settings.command?.trim()) { + return null; + } + const mediaPath = normalizeInboundMediaPath(media.path ?? media.url); + if (!mediaPath || !path.isAbsolute(mediaPath)) { + return null; + } + const stats = await fs.stat(mediaPath).catch(() => undefined); + if (!stats?.isFile()) { + return null; + } + const argv = buildAudioTranscriptArgv({ + args: settings.args, + mediaPath, + mimeType: normalizeMimeType(media.mimeType), + fileName: media.fileName, + }); + try { + const result = await execFileAsync(settings.command, argv, { + timeout: settings.timeoutMs, + maxBuffer: 1024 * 1024, + }); + const transcript = extractTranscriptText(result.stdout); + return transcript.trim() || null; + } catch (error) { + this.api.logger.warn( + `codex inbound audio transcription failed file=${mediaPath}: ${String(error)}`, + ); + return null; + } + } + async handleInboundClaim(event: { content: string; channel: string; @@ -2121,7 +2241,10 @@ export class CodexPluginController { if (!conversation) { return { handled: false }; } - const input = await buildInboundTurnInput(event); + const input = await buildInboundTurnInput({ + ...event, + transcribeAudio: async (media) => await this.transcribeInboundAudio(media), + }); const requiresStructuredInput = !isQueueCompatibleTurnInput(event.content, input); const activeKey = buildConversationKey(conversation); const active = this.activeRuns.get(activeKey); diff --git a/src/types.ts b/src/types.ts index 8bbf2e9..a56e27f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,14 @@ export type PluginSettings = { defaultWorkspaceDir?: string; defaultModel?: string; defaultServiceTier?: string; + inboundAudioTranscription?: InboundAudioTranscriptionSettings; +}; + +export type InboundAudioTranscriptionSettings = { + enabled: boolean; + command?: string; + args: string[]; + timeoutMs: number; }; export type CodexPlanStep = { From 0bf4d378c16a7461bf8fa7cf9a1402d0f002956b Mon Sep 17 00:00:00 2001 From: Yehonal Date: Mon, 20 Apr 2026 18:57:38 +0000 Subject: [PATCH 08/19] Plugin: recover missing local Discord CAS bindings --- src/controller.test.ts | 64 ++++++++++++++++++++++++++-- src/controller.ts | 95 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 8154990..ff72349 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -27,6 +27,10 @@ const telegramSdkState = vi.hoisted(() => ({ resolveTelegramAccount: vi.fn(() => ({ accountId: "default", token: "telegram-token" })), })); +const conversationRuntimeState = vi.hoisted(() => ({ + getCurrentPluginConversationBinding: vi.fn(async () => null), +})); + vi.mock("openclaw/plugin-sdk/discord", () => ({ buildDiscordComponentMessage: discordSdkState.buildDiscordComponentMessage, editDiscordComponentMessage: discordSdkState.editDiscordComponentMessage, @@ -38,6 +42,10 @@ vi.mock("openclaw/plugin-sdk/telegram-account", () => ({ resolveTelegramAccount: telegramSdkState.resolveTelegramAccount, })); +vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({ + getCurrentPluginConversationBinding: conversationRuntimeState.getCurrentPluginConversationBinding, +})); + function makeStateDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-app-server-test-")); } @@ -647,6 +655,8 @@ beforeEach(() => { discordSdkState.registerBuiltDiscordComponentMessage.mockClear(); discordSdkState.resolveDiscordAccount.mockClear(); telegramSdkState.resolveTelegramAccount.mockClear(); + conversationRuntimeState.getCurrentPluginConversationBinding.mockClear(); + conversationRuntimeState.getCurrentPluginConversationBinding.mockResolvedValue(null); vi.spyOn(CodexAppServerClient.prototype, "logStartupProbe").mockResolvedValue(); vi.stubGlobal( "fetch", @@ -4138,8 +4148,45 @@ describe("Discord controller flows", () => { expect(startTurn).toHaveBeenCalled(); }); - it("does not claim inbound Discord messages when only core binding state exists", async () => { - const { controller } = await createControllerHarness(); + it("recovers a missing local Discord binding from the runtime binding state", async () => { + const { controller, clientMock } = await createControllerHarness(); + await (controller as any).store.upsertConversationEndpoint({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + }, + endpointId: "windows-main", + updatedAt: Date.now(), + }); + conversationRuntimeState.getCurrentPluginConversationBinding.mockImplementation(async () => ({ + bindingId: "b1", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/root/.openclaw/extensions/openclaw-codex-app-server", + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + boundAt: Date.now(), + summary: "Bind this conversation to Codex thread 019dab3f-09f7-7a42-8d10-1f2949ce6f30.", + } as any)); + clientMock.readThreadState.mockResolvedValue({ + threadId: "019dab3f-09f7-7a42-8d10-1f2949ce6f30", + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "never", + sandbox: "danger-full-access", + }); + const startTurn = vi.fn(() => ({ + result: Promise.resolve({ + threadId: "019dab3f-09f7-7a42-8d10-1f2949ce6f30", + text: "hello", + }), + getThreadId: () => "019dab3f-09f7-7a42-8d10-1f2949ce6f30", + queueMessage: vi.fn(async () => true), + })); + (controller as any).client.startTurn = startTurn; const result = await controller.handleInboundClaim({ content: "who are you?", @@ -4150,7 +4197,18 @@ describe("Discord controller flows", () => { metadata: { guildId: "guild-1" }, }); - expect(result).toEqual({ handled: false }); + expect(result).toEqual({ handled: true }); + expect(startTurn).toHaveBeenCalled(); + expect((controller as any).store.getBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:1481858418548412579", + })).toEqual(expect.objectContaining({ + threadId: "019dab3f-09f7-7a42-8d10-1f2949ce6f30", + endpointId: "windows-main", + workspaceDir: "/repo/openclaw", + permissionsMode: "full-access", + })); }); it("uses a raw Discord channel id for the typing lease on inbound claims", async () => { diff --git a/src/controller.ts b/src/controller.ts index 6c3e88d..6e0410b 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -16,6 +16,7 @@ import type { ReplyPayload, ConversationRef, } from "openclaw/plugin-sdk"; +import { getCurrentPluginConversationBinding } from "openclaw/plugin-sdk/conversation-runtime"; import { resolvePluginSettings, resolveWorkspaceDir } from "./config.js"; import { CodexAppServerModeClient, type ActiveCodexRun, isMissingThreadError } from "./client.js"; import { getThreadDisplayTitle } from "./thread-display.js"; @@ -172,6 +173,7 @@ type ActiveRunRecord = { }; const execFileAsync = promisify(execFile); +const PLUGIN_ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const require = createRequire(import.meta.url); const TEXT_ATTACHMENT_FILE_EXTENSIONS = new Set([ ".json", @@ -2122,6 +2124,78 @@ export class CodexPluginController { return this.getClientForEndpoint(); } + private toPluginBindingConversation(conversation: ConversationTarget): { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + threadId?: string | number; + } { + return { + channel: conversation.channel, + accountId: conversation.accountId, + conversationId: conversation.conversationId, + parentConversationId: conversation.parentConversationId, + threadId: conversation.threadId, + }; + } + + private parseCodexThreadIdFromBindingSummary(summary?: string): string | null { + const trimmed = summary?.trim(); + if (!trimmed) { + return null; + } + const match = trimmed.match(/\b([0-9a-f]{8}-[0-9a-f-]{27})\b/i); + return match?.[1] ?? null; + } + + private async tryRecoverMissingLocalBinding( + conversation: ConversationTarget, + ): Promise { + const runtimeBinding = await getCurrentPluginConversationBinding({ + pluginRoot: PLUGIN_ROOT_DIR, + conversation: this.toPluginBindingConversation(conversation), + }).catch(() => null); + if (!runtimeBinding) { + return null; + } + const threadId = this.parseCodexThreadIdFromBindingSummary(runtimeBinding.summary); + if (!threadId) { + this.api.logger.debug?.( + `codex binding recovery skipped, runtime summary missing thread id conversation=${conversation.conversationId}`, + ); + return null; + } + const threadState = await this.client + .readThreadState({ + profile: "default", + sessionKey: buildPluginSessionKey(threadId), + threadId, + }) + .catch(() => undefined); + const workspaceDir = threadState?.cwd?.trim(); + if (!workspaceDir) { + this.api.logger.warn( + `codex binding recovery could not read workspace for conversation=${conversation.conversationId} thread=${threadId}`, + ); + return null; + } + const permissionsMode = + threadState?.approvalPolicy?.trim() === "never" && + threadState?.sandbox?.trim() === "danger-full-access" + ? "full-access" + : "default"; + const recovered = await this.bindConversation(conversation, { + threadId, + workspaceDir, + threadTitle: threadState?.threadName?.trim() || undefined, + permissionsMode, + }); + this.api.logger.warn( + `codex recovered missing local binding conversation=${conversation.conversationId} thread=${threadId}`, + ); + return recovered; + } async handleConversationBindingResolved( event: PluginConversationBindingResolvedEvent, ): Promise { @@ -2144,6 +2218,12 @@ export class CodexPluginController { }; const pending = this.store.getPendingBind(conversation); if (!pending) { + if (event.status === "approved") { + const recovered = await this.tryRecoverMissingLocalBinding(conversation); + if (recovered) { + return; + } + } this.api.logger.debug?.( `codex binding approved without pending local bind conversation=${conversation.conversationId}`, ); @@ -2293,7 +2373,11 @@ export class CodexPluginController { } const existingBinding = this.store.getBinding(conversation); const hydratedBinding = existingBinding ? null : await this.hydrateApprovedBinding(conversation); - const resolvedBinding = existingBinding ?? hydratedBinding?.binding ?? null; + const recoveredBinding = + existingBinding || hydratedBinding?.binding + ? null + : await this.tryRecoverMissingLocalBinding(conversation); + const resolvedBinding = existingBinding ?? hydratedBinding?.binding ?? recoveredBinding ?? null; this.api.logger.debug?.( `codex inbound claim channel=${conversation.channel} account=${conversation.accountId} conversation=${conversation.conversationId} parent=${conversation.parentConversationId ?? ""} local=${resolvedBinding ? "yes" : "no"}`, ); @@ -2688,6 +2772,15 @@ export class CodexPluginController { ? "Detached this conversation from Codex." : "This conversation is not currently bound to Codex.", }; + case "cas_reset": + if (!conversation) { + return { text: "This command needs a Telegram or Discord conversation." }; + } + await bindingApi.detachConversationBinding?.().catch(() => undefined); + await this.unbindConversation(conversation); + return { + text: "Reset Codex conversation state for this chat. The binding, pending requests, and stale callbacks were cleared. Run /cas_resume to bind again.", + }; case "cas_status": return await this.handleStatusCommand( conversation, From 750d4d471a5ef1c2c96a56c96bd303052be84c4a Mon Sep 17 00:00:00 2001 From: Yehonal Date: Mon, 20 Apr 2026 19:04:54 +0000 Subject: [PATCH 09/19] docs: document cas_reset recovery command --- README.md | 1 + src/commands.ts | 1 + src/help.ts | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 54a7db2..c97bb17 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ The manual `/cas_*` commands still remain useful as the human-facing fallback an | `/cas_status --fast`, `/cas_status --no-fast` | Change fast mode and refresh the status card. | Fast mode is only available on supported models such as GPT-5.4+. | | `/cas_status --yolo`, `/cas_status --no-yolo` | Change permissions mode and refresh the status card. | `--yolo` selects Full Access. | | `/cas_detach` | Unbind this conversation from Codex. | Stops routing plain text from this conversation into the bound thread. | +| `/cas_reset` | Force-clear Codex state for this conversation. | Recovery command for stale binds; clears the binding plus pending bind/request/callback state, then tells you to run `/cas_resume`. | | `/cas_stop` | Interrupt the active Codex run. | Only applies when a turn is currently in progress. | | `/cas_steer ` | Send follow-up steer text to an active run. | Example: `/cas_steer focus on the failing tests first` | | `/cas_plan ` | Ask Codex to plan instead of execute. | The plugin relays plan questions and the final plan back into chat. | diff --git a/src/commands.ts b/src/commands.ts index 85ebac5..c6cabcf 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,7 @@ export const COMMANDS = [ ["cas_resume", "Resume or create a Codex thread, with optional model, fast mode, and permissions overrides."], ["cas_detach", "Detach this conversation from the current Codex thread."], + ["cas_reset", "Force-clear Codex binding state for this conversation and detach it."], ["cas_status", "Show Codex status and controls, or apply model, fast mode, and permissions overrides."], ["cas_stop", "Stop the active Codex turn."], ["cas_steer", "Send a steer message to the active Codex turn."], diff --git a/src/help.ts b/src/help.ts index 4e9c1f2..0006613 100644 --- a/src/help.ts +++ b/src/help.ts @@ -43,6 +43,12 @@ export const COMMAND_HELP: Record = { usage: "/cas_detach", examples: ["/cas_detach"], }, + cas_reset: { + summary: COMMAND_SUMMARY.cas_reset, + usage: "/cas_reset", + examples: ["/cas_reset"], + notes: "Use this as a recovery command when a conversation looks stuck or stale. It clears the stored binding state for this conversation, including pending bind/request UI state, and detaches from Codex.", + }, cas_status: { summary: COMMAND_SUMMARY.cas_status, usage: "/cas_status [--model ] [--fast|--no-fast] [--yolo|--no-yolo]", From 7712fbe6ef14858c087fa1ac1d01112babf951d8 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Tue, 21 Apr 2026 17:17:30 +0000 Subject: [PATCH 10/19] fix(discord): isolate CAS thread bindings by thread scope --- src/controller.test.ts | 205 ++++++++++++++++++++++++++++++++++++++++- src/controller.ts | 127 +++++++++++++++++++------ src/state.test.ts | 124 +++++++++++++++++++++++++ src/state.ts | 63 ++++++++++++- src/types.ts | 2 +- 5 files changed, 485 insertions(+), 36 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index ff72349..083277e 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -4148,6 +4148,129 @@ describe("Discord controller flows", () => { expect(startTurn).toHaveBeenCalled(); }); + it("routes Discord thread inbound claims to the thread conversation instead of the parent channel", async () => { + const { controller } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-2", + parentConversationId: "channel:parent-1", + threadId: "thread-2", + }, + sessionKey: "session-2", + threadId: "codex-thread-2", + workspaceDir: "/repo/openclaw", + updatedAt: Date.now(), + }); + const startTurn = vi.fn(() => ({ + result: Promise.resolve({ + threadId: "codex-thread-2", + text: "hello from thread 2", + }), + getThreadId: () => "codex-thread-2", + queueMessage: vi.fn(async () => true), + })); + (controller as any).client.startTurn = startTurn; + + const result = await controller.handleInboundClaim({ + content: "message from second discord thread", + channel: "discord", + accountId: "default", + conversationId: "parent-1", + parentConversationId: "parent-1", + threadId: "thread-2", + isGroup: true, + metadata: { guildId: "guild-1" }, + }); + + expect(result).toEqual({ handled: true }); + expect(startTurn).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ + threadId: "codex-thread-2", + workspaceDir: "/repo/openclaw", + }), + conversation: expect.objectContaining({ + conversationId: "channel:thread-2", + parentConversationId: "channel:parent-1", + threadId: "thread-2", + }), + }), + ); + }); + + it("keeps Discord bindings for sibling threads distinct when inbound events arrive from the same parent channel", async () => { + const { controller } = await createControllerHarness(); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-a", + parentConversationId: "channel:parent-1", + threadId: "thread-a", + }, + sessionKey: "session-a", + threadId: "codex-thread-a", + workspaceDir: "/repo/a", + updatedAt: Date.now(), + }); + await (controller as any).store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-b", + parentConversationId: "channel:parent-1", + threadId: "thread-b", + }, + sessionKey: "session-b", + threadId: "codex-thread-b", + workspaceDir: "/repo/b", + updatedAt: Date.now(), + }); + const startTurn = vi.fn((params: any) => ({ + result: Promise.resolve({ + threadId: params.binding?.threadId ?? "unknown", + text: "ok", + }), + getThreadId: () => params.binding?.threadId ?? "unknown", + queueMessage: vi.fn(async () => true), + })); + (controller as any).client.startTurn = startTurn; + + const resultA = await controller.handleInboundClaim({ + content: "from thread A", + channel: "discord", + accountId: "default", + conversationId: "parent-1", + parentConversationId: "parent-1", + threadId: "thread-a", + isGroup: true, + metadata: { guildId: "guild-1" }, + }); + const resultB = await controller.handleInboundClaim({ + content: "from thread B", + channel: "discord", + accountId: "default", + conversationId: "parent-1", + parentConversationId: "parent-1", + threadId: "thread-b", + isGroup: true, + metadata: { guildId: "guild-1" }, + }); + + expect(resultA).toEqual({ handled: true }); + expect(resultB).toEqual({ handled: true }); + expect(startTurn).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ binding: expect.objectContaining({ threadId: "codex-thread-a" }) }), + ); + expect(startTurn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ binding: expect.objectContaining({ threadId: "codex-thread-b" }) }), + ); + }); + it("recovers a missing local Discord binding from the runtime binding state", async () => { const { controller, clientMock } = await createControllerHarness(); await (controller as any).store.upsertConversationEndpoint({ @@ -4211,6 +4334,79 @@ describe("Discord controller flows", () => { })); }); + it("recovers an approved Discord binding event when no pending local bind exists", async () => { + const { controller, clientMock } = await createControllerHarness(); + const codexThreadId = "019dab3f-09f7-7a42-8d10-1f2949ce6f30"; + conversationRuntimeState.getCurrentPluginConversationBinding.mockImplementation(async () => ({ + bindingId: "b1", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/root/.openclaw/extensions/openclaw-codex-app-server", + channel: "discord", + accountId: "default", + conversationId: "channel:1485612939816996900", + parentConversationId: "channel:1485612939816996956", + threadId: "1485612939816996900", + boundAt: Date.now(), + summary: `Bind this conversation to Codex thread ${codexThreadId}.`, + } as any)); + clientMock.readThreadState.mockResolvedValue({ + threadId: codexThreadId, + threadName: "Discord Thread", + model: "openai/gpt-5.4", + cwd: "/repo/openclaw", + serviceTier: "default", + approvalPolicy: "on-request", + sandbox: "workspace-write", + }); + + await controller.handleConversationBindingResolved({ + status: "approved", + binding: { + bindingId: "binding-1", + pluginId: "openclaw-codex-app-server", + pluginRoot: "/plugins/codex", + channel: "discord", + accountId: "default", + conversationId: "channel:1485612939816996900", + parentConversationId: "channel:1485612939816996956", + threadId: "1485612939816996900", + boundAt: Date.now(), + }, + decision: "allow-once", + request: { + summary: `Bind this conversation to Codex thread ${codexThreadId}.`, + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:1485612939816996900", + parentConversationId: "channel:1485612939816996956", + threadId: "1485612939816996900", + }, + }, + } as any); + + expect(conversationRuntimeState.getCurrentPluginConversationBinding).toHaveBeenCalledWith( + expect.objectContaining({ + conversation: expect.objectContaining({ + channel: "discord", + conversationId: "channel:1485612939816996900", + parentConversationId: "channel:1485612939816996956", + threadId: "1485612939816996900", + }), + }), + ); + expect((controller as any).store.getBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:1485612939816996900", + parentConversationId: "channel:1485612939816996956", + threadId: "1485612939816996900", + })).toEqual(expect.objectContaining({ + threadId: codexThreadId, + workspaceDir: "/repo/openclaw", + })); + }); + it("uses a raw Discord channel id for the typing lease on inbound claims", async () => { const { controller, discordTypingStart } = await createControllerHarness(); await (controller as any).store.upsertBinding({ @@ -4902,15 +5098,16 @@ describe("Discord controller flows", () => { ); }); - it("does not forward Discord thread ids into outbound adapter sends", async () => { + it("routes Discord thread replies through the parent channel with a thread id", async () => { const { controller, discordOutbound } = await createControllerHarnessWithoutLegacyDiscordRuntime(); const sent = await (controller as any).sendReply( { channel: "discord", accountId: "default", - conversationId: "channel:1485612939816996956", - threadId: 1485612939816996900, + conversationId: "channel:1485612939816996900", + parentConversationId: "channel:1485612939816996956", + threadId: "1485612939816996900", }, { text: "hello from a bound discord thread", @@ -4923,7 +5120,7 @@ describe("Discord controller flows", () => { | undefined; expect(outboundCall?.to).toBe("channel:1485612939816996956"); expect(outboundCall?.accountId).toBe("default"); - expect("threadId" in (outboundCall ?? {})).toBe(false); + expect(outboundCall?.threadId).toBe("1485612939816996900"); }); it("restarts a Discord bound run when the active queue path fails", async () => { diff --git a/src/controller.ts b/src/controller.ts index 6e0410b..ea0bad1 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -505,9 +505,8 @@ function normalizeDiscordChannelConversationId(raw?: string): string | undefined function resolveDiscordCommandConversation( ctx: PluginCommandContext, ): ConversationTarget | null { - const threadConversationId = normalizeDiscordChannelConversationId( - readCommandContextId(ctx, "messageThreadId"), - ); + const rawThreadId = readCommandContextId(ctx, "messageThreadId"); + const threadConversationId = normalizeDiscordChannelConversationId(rawThreadId); if (threadConversationId) { return { channel: "discord", @@ -516,6 +515,7 @@ function resolveDiscordCommandConversation( parentConversationId: normalizeDiscordChannelConversationId( readCommandContextId(ctx, "threadParentId"), ), + threadId: denormalizeDiscordConversationId(threadConversationId) ?? rawThreadId, }; } const candidates = [ctx.from, ctx.to] @@ -588,9 +588,41 @@ function toConversationTargetFromInbound(event: { } const channel = event.channel.trim().toLowerCase(); const conversationIdRaw = event.conversationId?.trim(); + const parentConversationId = + channel === "discord" + ? normalizeDiscordConversationId(event.parentConversationId) + : event.parentConversationId; + const discordNormalizedThreadId = + channel === "discord" + ? (() => { + if (typeof event.threadId === "string") { + const trimmed = event.threadId.trim(); + if (trimmed) { + return trimmed; + } + } + if (typeof event.threadId === "number" && Number.isFinite(event.threadId)) { + return String(Math.trunc(event.threadId)); + } + return undefined; + })() + : undefined; + const normalizedThreadId = + channel === "discord" + ? discordNormalizedThreadId + : typeof event.threadId === "number" + ? event.threadId + : typeof event.threadId === "string" + ? Number.isFinite(Number(event.threadId)) + ? Number(event.threadId) + : undefined + : undefined; const conversationId = channel === "discord" ? (() => { + if (discordNormalizedThreadId) { + return normalizeDiscordChannelConversationId(discordNormalizedThreadId); + } const normalized = normalizeDiscordConversationId(conversationIdRaw); if (!normalized) { return undefined; @@ -604,28 +636,28 @@ function toConversationTargetFromInbound(event: { return `${isChannel ? "channel" : "user"}:${normalized}`; })() : event.conversationId; - const parentConversationId = - channel === "discord" - ? normalizeDiscordConversationId(event.parentConversationId) - : event.parentConversationId; if (!conversationId) { return null; } + const resolvedThreadId = + channel === "discord" + ? normalizedThreadId ?? (() => { + if (parentConversationId) { + const candidate = denormalizeDiscordConversationId(conversationId); + const parentRaw = denormalizeDiscordConversationId(parentConversationId); + if (candidate && parentRaw && candidate !== parentRaw) { + return candidate; + } + } + return undefined; + })() + : normalizedThreadId; return { channel, accountId: event.accountId, conversationId, parentConversationId, - threadId: - channel === "discord" - ? undefined - : typeof event.threadId === "number" - ? event.threadId - : typeof event.threadId === "string" - ? Number.isFinite(Number(event.threadId)) - ? Number(event.threadId) - : undefined - : undefined, + threadId: resolvedThreadId, }; } @@ -2206,6 +2238,18 @@ export class CodexPluginController { conversationId: event.request.conversation.conversationId, parentConversationId: event.request.conversation.parentConversationId, threadId: (() => { + if (isDiscordChannel(event.request.conversation.channel)) { + if (typeof event.request.conversation.threadId === "string") { + return event.request.conversation.threadId.trim() || undefined; + } + if ( + typeof event.request.conversation.threadId === "number" && + Number.isFinite(event.request.conversation.threadId) + ) { + return String(Math.trunc(event.request.conversation.threadId)); + } + return undefined; + } if (typeof event.request.conversation.threadId === "number") { return event.request.conversation.threadId; } @@ -6050,10 +6094,12 @@ export class CodexPluginController { `codex discord picker send conversation=${conversation.conversationId} rows=${picker.buttons?.length ?? 0}`, ); const outbound = await this.loadDiscordOutboundAdapter(); + const discordRoute = this.resolveDiscordOutboundRoute(conversation); if (outbound?.sendPayload) { await outbound.sendPayload({ cfg: this.getOpenClawConfig(), - to: conversation.conversationId, + to: discordRoute.to, + threadId: discordRoute.threadId, accountId: conversation.accountId, payload: { text: picker.text, @@ -8105,10 +8151,12 @@ export class CodexPluginController { }; } } + const discordRoute = this.resolveDiscordOutboundRoute(conversation); const result = outbound?.sendPayload ? await outbound.sendPayload({ cfg: this.getOpenClawConfig(), - to: conversation.conversationId, + to: discordRoute.to, + threadId: discordRoute.threadId, accountId: conversation.accountId, mediaLocalRoots, payload: { @@ -8205,6 +8253,27 @@ export class CodexPluginController { return (await loadAdapter("discord")) as DiscordOutboundAdapter | undefined; } + private resolveDiscordOutboundRoute(conversation: ConversationTarget): { + to: string; + threadId?: string; + } { + const explicitThreadId = + typeof conversation.threadId === "string" + ? conversation.threadId.trim() || undefined + : typeof conversation.threadId === "number" && Number.isFinite(conversation.threadId) + ? String(Math.trunc(conversation.threadId)) + : undefined; + const inferredThreadId = + conversation.parentConversationId != null + ? denormalizeDiscordConversationId(conversation.conversationId) + : undefined; + const threadId = explicitThreadId ?? inferredThreadId; + const to = threadId && conversation.parentConversationId + ? conversation.parentConversationId + : conversation.conversationId; + return { to, threadId }; + } + private async sendTelegramTextChunk( outbound: TelegramOutboundAdapter | undefined, conversation: ConversationTarget, @@ -8329,10 +8398,12 @@ export class CodexPluginController { ): Promise<{ messageId: string; channelId?: string }> { const mediaUrl = opts?.mediaUrl; const mediaLocalRoots = opts?.mediaLocalRoots; + const discordRoute = this.resolveDiscordOutboundRoute(conversation); if (mediaUrl && outbound?.sendMedia) { return await outbound.sendMedia({ cfg: this.getOpenClawConfig(), - to: conversation.conversationId, + to: discordRoute.to, + threadId: discordRoute.threadId, text, mediaUrl, accountId: conversation.accountId, @@ -8342,7 +8413,8 @@ export class CodexPluginController { if (!mediaUrl && outbound?.sendText) { return await outbound.sendText({ cfg: this.getOpenClawConfig(), - to: conversation.conversationId, + to: discordRoute.to, + threadId: discordRoute.threadId, text, accountId: conversation.accountId, }); @@ -8422,9 +8494,11 @@ export class CodexPluginController { } private async unbindConversation(conversation: ConversationTarget): Promise { - const binding = this.store.getBinding(conversation); - if (binding?.pinnedBindingMessage) { - await this.unpinStoredBindingMessage(binding); + const bindings = this.store.listBindingsForConversationScope(conversation); + for (const binding of bindings) { + if (binding.pinnedBindingMessage) { + await this.unpinStoredBindingMessage(binding); + } } await this.store.removeBinding(conversation); } @@ -8443,7 +8517,8 @@ export class CodexPluginController { return await legacyTyping({ to: conversation.parentConversationId ?? conversation.conversationId, accountId: conversation.accountId, - messageThreadId: conversation.threadId, + messageThreadId: + typeof conversation.threadId === "number" ? conversation.threadId : undefined, }); } return await this.startTelegramTypingLease(conversation); @@ -8834,7 +8909,7 @@ export class CodexPluginController { conversation: ConversationTarget, name: string, ): Promise { - if (isTelegramChannel(conversation.channel) && conversation.threadId != null) { + if (isTelegramChannel(conversation.channel) && typeof conversation.threadId === "number") { const legacyRename = this.api.runtime.channel.telegram?.conversationActions?.renameTopic; if (typeof legacyRename === "function") { await legacyRename( diff --git a/src/state.test.ts b/src/state.test.ts index 2a2f9ca..ab07627 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -244,6 +244,130 @@ describe("state store", () => { ).toBeNull(); }); + it("clears Discord thread-scoped state even when reset is issued from the parent channel scope", async () => { + const store = await makeStore(); + await store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + parentConversationId: "channel:parent-1", + }, + sessionKey: buildPluginSessionKey("thread-1"), + threadId: "thread-1", + workspaceDir: "/tmp/work", + updatedAt: Date.now(), + }); + await store.upsertPendingBind({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + parentConversationId: "channel:parent-1", + }, + threadId: "thread-1", + workspaceDir: "/tmp/work", + updatedAt: Date.now(), + }); + await store.upsertPendingRequest({ + requestId: "req-discord-1", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + parentConversationId: "channel:parent-1", + }, + threadId: "thread-1", + workspaceDir: "/tmp/work", + state: { + requestId: "req-discord-1", + options: ["yes"], + expiresAt: Date.now() + 10_000, + }, + updatedAt: Date.now(), + }); + const callback = await store.putCallback({ + kind: "resume-thread", + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + parentConversationId: "channel:parent-1", + }, + threadId: "thread-1", + workspaceDir: "/tmp/work", + }); + + await store.removeBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:parent-1", + threadId: "thread-1", + }); + + expect(store.listBindingsForConversationScope({ + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + })).toHaveLength(0); + expect(store.getPendingBind({ + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + })).toBeNull(); + expect(store.getPendingRequestById("req-discord-1")).toBeNull(); + expect(store.getCallback(callback.token)).toBeNull(); + }); + + it("does not clear sibling Discord threads that share the same parent channel", async () => { + const store = await makeStore(); + await store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + parentConversationId: "channel:parent-1", + }, + sessionKey: buildPluginSessionKey("thread-1"), + threadId: "thread-1", + workspaceDir: "/tmp/work-1", + updatedAt: Date.now(), + }); + await store.upsertBinding({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:thread-2", + parentConversationId: "channel:parent-1", + }, + sessionKey: buildPluginSessionKey("thread-2"), + threadId: "thread-2", + workspaceDir: "/tmp/work-2", + updatedAt: Date.now(), + }); + + await store.removeBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:parent-1", + threadId: "thread-1", + }); + + expect(store.getBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:thread-1", + })).toBeNull(); + expect(store.getBinding({ + channel: "discord", + accountId: "default", + conversationId: "channel:thread-2", + })).toEqual(expect.objectContaining({ + sessionKey: buildPluginSessionKey("thread-2"), + workspaceDir: "/tmp/work-2", + })); + }); + it("persists conversation preferences in bindings across reload", async () => { const dir = await makeStoreDir(); const store = await makeStore(dir); diff --git a/src/state.ts b/src/state.ts index b154f6b..ca97aa9 100644 --- a/src/state.ts +++ b/src/state.ts @@ -216,6 +216,54 @@ type PutCallbackInput = ttlMs?: number; }; +function normalizeDiscordConversationAlias(raw: string | number | undefined): string | undefined { + if (raw == null) { + return undefined; + } + const trimmed = String(raw).trim(); + if (!trimmed) { + return undefined; + } + if (trimmed.startsWith("channel:") || trimmed.startsWith("user:")) { + return trimmed; + } + return `channel:${trimmed}`; +} + +function getConversationScopeAliases(target: ConversationTarget): Set { + const aliases = new Set(); + const conversationId = target.conversationId.trim(); + if (conversationId) { + aliases.add(conversationId); + } + if (target.channel.trim().toLowerCase() !== "discord") { + return aliases; + } + const threadConversationId = normalizeDiscordConversationAlias(target.threadId); + if (threadConversationId) { + aliases.add(threadConversationId); + } + return aliases; +} + +function matchesConversationScope(target: ConversationTarget, candidate: ConversationTarget): boolean { + const targetChannel = target.channel.trim().toLowerCase(); + if (targetChannel !== candidate.channel.trim().toLowerCase()) { + return false; + } + if (target.accountId.trim() !== candidate.accountId.trim()) { + return false; + } + if (targetChannel !== "discord") { + return toConversationKey(target) === toConversationKey(candidate); + } + const aliases = getConversationScopeAliases(target); + if (aliases.size === 0) { + return false; + } + return aliases.has(candidate.conversationId.trim()); +} + function toConversationKey(target: ConversationTarget): string { const channel = target.channel.trim().toLowerCase(); return [ @@ -388,6 +436,12 @@ export class PluginStateStore { return [...this.snapshot.bindings]; } + listBindingsForConversationScope(target: ConversationTarget): StoredBinding[] { + return this.snapshot.bindings.filter((entry) => + matchesConversationScope(target, entry.conversation as ConversationTarget), + ); + } + getBinding(target: ConversationTarget): StoredBinding | null { const key = toConversationKey(target); return this.snapshot.bindings.find((entry) => toConversationKey(entry.conversation) === key) ?? null; @@ -432,18 +486,17 @@ export class PluginStateStore { } async removeBinding(target: ConversationTarget): Promise { - const key = toConversationKey(target); this.snapshot.bindings = this.snapshot.bindings.filter( - (entry) => toConversationKey(entry.conversation) !== key, + (entry) => !matchesConversationScope(target, entry.conversation as ConversationTarget), ); this.snapshot.pendingBinds = this.snapshot.pendingBinds.filter( - (entry) => toConversationKey(entry.conversation) !== key, + (entry) => !matchesConversationScope(target, entry.conversation as ConversationTarget), ); this.snapshot.pendingRequests = this.snapshot.pendingRequests.filter( - (entry) => toConversationKey(entry.conversation) !== key, + (entry) => !matchesConversationScope(target, entry.conversation as ConversationTarget), ); this.snapshot.callbacks = this.snapshot.callbacks.filter( - (entry) => toConversationKey(entry.conversation) !== key, + (entry) => !matchesConversationScope(target, entry.conversation as ConversationTarget), ); await this.save(); } diff --git a/src/types.ts b/src/types.ts index a56e27f..33978a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -625,7 +625,7 @@ export type StoreSnapshot = { }; export type ConversationTarget = ConversationRef & { - threadId?: number; + threadId?: number | string; }; export type CommandButtons = PluginInteractiveButtons; From 4620e38172ba4f9a9223cb40a1981c39c8138d7f Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 30 Apr 2026 21:44:26 +0000 Subject: [PATCH 11/19] fix: apply node-derived endpoint fallback in cas_resume --- src/client.ts | 9 +-------- src/controller.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++ src/controller.ts | 43 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/client.ts b/src/client.ts index ae974f7..a659cde 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2419,14 +2419,7 @@ export function isMissingThreadError(error: unknown): boolean { ); } -function buildFullAccessPluginSettings( - settings: ClientEndpointSettings, -): ClientEndpointSettings | null { - if (settings.transport === "websocket") { - return { - ...settings, - }; - } +function buildFullAccessPluginSettings(settings: ClientEndpointSettings): ClientEndpointSettings | null { if (settings.transport !== "stdio") { return null; } diff --git a/src/controller.test.ts b/src/controller.test.ts index 083277e..0a14b31 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -7380,6 +7380,51 @@ describe("Discord controller flows", () => { expect(deriveSpy).toHaveBeenCalledWith({ host: "node", node: "nestdev" }); }); + it("uses the derived node endpoint in /cas_resume when no configured match exists", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + ], + }); + vi.spyOn(controller as any, "getSelectedEndpointResolutionWithNodeFallback").mockResolvedValue({ + endpointId: "auto-node-nestdev", + source: "auto-node", + nodeId: "nestdev", + }); + const listSpy = vi.spyOn(controller as any, "handleListCommand").mockResolvedValue({ + text: "picker body", + }); + + const reply = await controller.handleCommand( + "cas_resume", + buildDiscordCommandContext({ + config: { + tools: { + exec: { + host: "node", + node: "nestdev", + }, + }, + }, + }), + ); + + expect(listSpy).toHaveBeenCalledWith( + expect.anything(), + null, + "auto-node-nestdev", + "", + "discord", + ); + expect(reply.text).toContain("picker body"); + expect(reply.text).toContain("Resolved endpoint: auto-node-nestdev (auto from node: nestdev)"); + }); + it("falls back to default endpoint when node-derived probe is unavailable", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", diff --git a/src/controller.ts b/src/controller.ts index ea0bad1..470c139 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2044,6 +2044,39 @@ export class CodexPluginController { }; } + private async getSelectedEndpointResolutionWithNodeFallback( + conversation: ConversationTarget | null | undefined, + ): Promise { + const manualEndpointId = this.getManualEndpointId(conversation); + if (manualEndpointId) { + return { + endpointId: manualEndpointId, + source: "manual", + }; + } + const execContext = this.readExecContextFromConfig(this.getOpenClawConfig()); + const autoEndpointId = this.resolveEndpointIdFromExecContext(execContext); + if (autoEndpointId) { + return { + endpointId: autoEndpointId, + source: "auto-node", + nodeId: execContext?.node?.trim() || undefined, + }; + } + const derivedEndpointId = await this.tryRegisterNodeDerivedEndpoint(execContext); + if (derivedEndpointId) { + return { + endpointId: derivedEndpointId, + source: "auto-node", + nodeId: execContext?.node?.trim() || undefined, + }; + } + return { + endpointId: this.settings.defaultEndpoint, + source: "default", + }; + } + private async setSelectedEndpointId(conversation: ConversationTarget, endpointId: string): Promise { await this.store.upsertConversationEndpoint({ conversation: { @@ -2776,8 +2809,11 @@ export class CodexPluginController { switch (commandName) { case "cas_resume": { - const resolvedEndpointText = conversation - ? `Resolved endpoint: ${this.formatEndpointResolutionLabel(this.getSelectedEndpointResolution(conversation))}` + const resolvedEndpoint = conversation + ? await this.getSelectedEndpointResolutionWithNodeFallback(conversation) + : undefined; + const resolvedEndpointText = resolvedEndpoint + ? `Resolved endpoint: ${this.formatEndpointResolutionLabel(resolvedEndpoint)}` : undefined; const withResolvedEndpoint = (reply: ReplyPayload): ReplyPayload => { if (!resolvedEndpointText) { @@ -2983,7 +3019,8 @@ export class CodexPluginController { if (parsed.error) { return { text: parsed.error }; } - const selectedEndpointId = this.getSelectedEndpointId(conversation, binding); + const selectedEndpoint = await this.getSelectedEndpointResolutionWithNodeFallback(conversation); + const selectedEndpointId = selectedEndpoint.endpointId; const resumeBinding = binding && this.getEndpointIdForBinding(binding) === selectedEndpointId ? binding : null; const resumePendingBind = From d0cc346e5ff056eeb8db5ed11cd1ba14b7de860d Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 30 Apr 2026 22:29:19 +0000 Subject: [PATCH 12/19] fix: preserve recovered endpoint selection and refresh controller tests --- src/controller.test.ts | 89 ++++++++++++++++++++++++++---------------- src/controller.ts | 19 ++++++--- 2 files changed, 69 insertions(+), 39 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 0a14b31..636760b 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -303,7 +303,7 @@ async function createControllerHarness(pluginConfigOverrides: Record []), }; - (controller as any).client = clientMock; + setControllerClient(controller, clientMock); (controller as any).readThreadHasChanges = vi.fn(async () => false); return { controller, @@ -321,6 +321,17 @@ async function createControllerHarness(pluginConfigOverrides: Record; + clients.clear(); + clients.set("default", client); + Object.defineProperty(instance as object, "client", { + configurable: true, + get: () => client, + }); + (instance as any).getClientForEndpoint = vi.fn((endpointId?: string) => clients.get(endpointId ?? "default") ?? client); +} + async function createControllerHarnessWithPluginConfig(pluginConfigOverrides: Record) { const { api, @@ -396,7 +407,7 @@ async function createControllerHarnessWithPluginConfig(pluginConfigOverrides: Re })), readRateLimits: vi.fn(async () => []), }; - (controller as any).client = clientMock; + setControllerClient(controller, clientMock); (controller as any).readThreadHasChanges = vi.fn(async () => false); return { controller, @@ -451,7 +462,7 @@ async function createControllerHarnessWithoutTelegramOutbound() { })), readRateLimits: vi.fn(async () => []), }; - (controller as any).client = clientMock; + setControllerClient(controller, clientMock); (controller as any).readThreadHasChanges = vi.fn(async () => false); return { controller, @@ -492,7 +503,7 @@ async function createControllerHarnessWithoutTelegramPayloadSupport() { })), readRateLimits: vi.fn(async () => []), }; - (controller as any).client = clientMock; + setControllerClient(controller, clientMock); (controller as any).readThreadHasChanges = vi.fn(async () => false); return { controller, @@ -543,7 +554,7 @@ async function createControllerHarnessWithoutLegacyDiscordRuntime() { })), readRateLimits: vi.fn(async () => []), }; - (controller as any).client = clientMock; + setControllerClient(controller, clientMock); (controller as any).readThreadHasChanges = vi.fn(async () => false); return { controller, @@ -593,7 +604,7 @@ async function createControllerHarnessWithoutDiscordSendSurfaces() { })), readRateLimits: vi.fn(async () => []), }; - (controller as any).client = clientMock; + setControllerClient(controller, clientMock); (controller as any).readThreadHasChanges = vi.fn(async () => false); return { controller }; } @@ -2340,17 +2351,22 @@ describe("Discord controller flows", () => { | undefined; const buttons = firstCall?.[2]?.buttons ?? []; - expect(buttons).toHaveLength(5); - expect(buttons[0][0].text).toBe("Select Model"); - expect(buttons[0][1].text).toBe("Reasoning: Default"); - expect(buttons[1][0].text).toBe("Fast: toggle"); - expect(buttons[1][1].text).toBe("Permissions: toggle"); - expect(buttons[2][0].text).toBe("Compact"); - expect(buttons[2][1].text).toBe("Stop"); - expect(buttons[3][0].text).toBe("Refresh"); - expect(buttons[3][1].text).toBe("Detach"); - expect(buttons[4][0].text).toBe("Skills"); - expect(buttons[4][1].text).toBe("MCPs"); + const buttonTexts = buttons.flatMap((row: Array<{ text: string }>) => row.map((button) => button.text)); + expect(buttonTexts).toEqual( + expect.arrayContaining([ + "Select Model", + "Endpoint", + "Reasoning: Default", + "Fast: toggle", + "Permissions: toggle", + "Compact", + "Stop", + "Refresh", + "Detach", + "Skills", + "MCPs", + ]), + ); const kinds = buttons.flatMap((row: Array<{ callback_data: string }>) => { return row.map((button) => { const token = button.callback_data.split(":").pop() ?? ""; @@ -2480,11 +2496,16 @@ describe("Discord controller flows", () => { expect(text).toContain("Model: unknown"); expect(text).toContain("saved as defaults until then"); - expect(buttons).toHaveLength(5); - expect(buttons[0][0].text).toBe("Select Model"); - expect(buttons[0][1].text).toBe("Reasoning: Default"); - expect(buttons[1][0].text).toBe("Fast: toggle"); - expect(buttons[1][1].text).toBe("Permissions: toggle"); + const buttonTexts = buttons.flatMap((row: Array<{ text: string }>) => row.map((button) => button.text)); + expect(buttonTexts).toEqual( + expect.arrayContaining([ + "Select Model", + "Endpoint", + "Reasoning: Default", + "Fast: toggle", + "Permissions: toggle", + ]), + ); }); it("hides the fast button on status controls when the current model does not support it", async () => { @@ -4187,15 +4208,9 @@ describe("Discord controller flows", () => { expect(result).toEqual({ handled: true }); expect(startTurn).toHaveBeenCalledWith( expect.objectContaining({ - binding: expect.objectContaining({ - threadId: "codex-thread-2", - workspaceDir: "/repo/openclaw", - }), - conversation: expect.objectContaining({ - conversationId: "channel:thread-2", - parentConversationId: "channel:parent-1", - threadId: "thread-2", - }), + existingThreadId: "codex-thread-2", + sessionKey: "session-2", + workspaceDir: "/repo/openclaw", }), ); }); @@ -4263,11 +4278,19 @@ describe("Discord controller flows", () => { expect(resultB).toEqual({ handled: true }); expect(startTurn).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ binding: expect.objectContaining({ threadId: "codex-thread-a" }) }), + expect.objectContaining({ + existingThreadId: "codex-thread-a", + sessionKey: "session-a", + workspaceDir: "/repo/a", + }), ); expect(startTurn).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ binding: expect.objectContaining({ threadId: "codex-thread-b" }) }), + expect.objectContaining({ + existingThreadId: "codex-thread-b", + sessionKey: "session-b", + workspaceDir: "/repo/b", + }), ); }); diff --git a/src/controller.ts b/src/controller.ts index 470c139..b3c0775 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2005,11 +2005,13 @@ export class CodexPluginController { if (!conversation) { return undefined; } - const stored = this.store.getConversationEndpoint(conversation)?.endpointId?.trim(); - if (stored && this.settings.endpoints.some((entry) => entry.id === stored)) { - return stored; - } - return undefined; + const prefixedConversation = !conversation.conversationId.includes(":") + ? { ...conversation, conversationId: `${conversation.channel}:${conversation.conversationId}` } + : null; + return ( + this.store.getConversationEndpoint(conversation) + ?? (prefixedConversation ? this.store.getConversationEndpoint(prefixedConversation) : null) + )?.endpointId?.trim() || undefined; } private getSelectedEndpointId( @@ -2231,7 +2233,11 @@ export class CodexPluginController { ); return null; } - const threadState = await this.client + const endpointSelection = await this.getSelectedEndpointResolutionWithNodeFallback(conversation); + const recoveryClient = this.settings.endpoints.some((entry) => entry.id === endpointSelection.endpointId) + ? this.getClientForEndpoint(endpointSelection.endpointId) + : this.client; + const threadState = await recoveryClient .readThreadState({ profile: "default", sessionKey: buildPluginSessionKey(threadId), @@ -2252,6 +2258,7 @@ export class CodexPluginController { : "default"; const recovered = await this.bindConversation(conversation, { threadId, + endpointId: endpointSelection.endpointId, workspaceDir, threadTitle: threadState?.threadName?.trim() || undefined, permissionsMode, From 722cb00a3ccb027963b9ad94fbccca90d919b8a7 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Fri, 1 May 2026 05:44:06 +0000 Subject: [PATCH 13/19] fix: use node-aware endpoint resolution across CAS controls --- src/controller.test.ts | 6 ++++-- src/controller.ts | 40 ++++++++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 636760b..204ea91 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -683,6 +683,8 @@ async function flushAsyncWork(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); } describe("Discord controller flows", () => { @@ -7531,13 +7533,13 @@ describe("Discord controller flows", () => { updatedAt: Date.now(), }); - expect( + await expect( (controller as any).getSelectedEndpointResolution({ channel: "discord", accountId: "default", conversationId: "channel:chan-1", }), - ).toMatchObject({ endpointId: "default", source: "manual" }); + ).resolves.toMatchObject({ endpointId: "default", source: "manual" }); }); it("clears the manual endpoint override and falls back to automatic node resolution", async () => { diff --git a/src/controller.ts b/src/controller.ts index b3c0775..c901e69 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -2014,16 +2014,16 @@ export class CodexPluginController { )?.endpointId?.trim() || undefined; } - private getSelectedEndpointId( + private async getSelectedEndpointId( conversation: ConversationTarget | null | undefined, _binding?: StoredBinding | StoredPendingBind | null, - ): string { - return this.getSelectedEndpointResolution(conversation).endpointId; + ): Promise { + return (await this.getSelectedEndpointResolution(conversation)).endpointId; } - private getSelectedEndpointResolution( + private async getSelectedEndpointResolution( conversation: ConversationTarget | null | undefined, - ): EndpointResolution { + ): Promise { const manualEndpointId = this.getManualEndpointId(conversation); if (manualEndpointId) { return { @@ -2040,6 +2040,14 @@ export class CodexPluginController { nodeId: execContext?.node?.trim() || undefined, }; } + const derivedEndpointId = await this.tryRegisterNodeDerivedEndpoint(execContext); + if (derivedEndpointId) { + return { + endpointId: derivedEndpointId, + source: "auto-node", + nodeId: execContext?.node?.trim() || undefined, + }; + } return { endpointId: this.settings.defaultEndpoint, source: "default", @@ -3730,7 +3738,7 @@ export class CodexPluginController { statusMessage?: InteractiveMessageRef; }, ): Promise { - const selection = this.getSelectedEndpointResolution(conversation); + const selection = await this.getSelectedEndpointResolution(conversation); const manualEndpointId = this.getManualEndpointId(conversation); const buttons: PluginInteractiveButtons = []; if (manualEndpointId) { @@ -4316,7 +4324,7 @@ export class CodexPluginController { const profile = this.getPermissionsMode(binding); if (!binding) { const models = await this.getClientForEndpoint( - this.getSelectedEndpointId(conversation, binding), + await this.getSelectedEndpointId(conversation, binding), ).listModels({ profile }); return { text: formatModels(models) }; } @@ -4392,7 +4400,7 @@ export class CodexPluginController { if (parsed.error) { return { text: parsed.error }; } - const currentSelection = this.getSelectedEndpointResolution(conversation); + const currentSelection = await this.getSelectedEndpointResolution(conversation); if (!parsed.endpointId) { const picker = await this.buildEndpointPicker(conversation, binding); return buildReplyWithButtons(picker.text, picker.buttons); @@ -4400,7 +4408,7 @@ export class CodexPluginController { const requested = parsed.endpointId.trim(); if (["auto", "clear"].includes(requested.toLowerCase())) { await this.clearSelectedEndpointId(conversation); - const nextSelection = this.getSelectedEndpointResolution(conversation); + const nextSelection = await this.getSelectedEndpointResolution(conversation); return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; } const endpoint = this.settings.endpoints.find((entry) => entry.id === requested); @@ -4418,7 +4426,7 @@ export class CodexPluginController { }; } await this.setSelectedEndpointId(conversation, endpoint.id || requested); - const nextSelection = this.getSelectedEndpointResolution(conversation); + const nextSelection = await this.getSelectedEndpointResolution(conversation); return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; } @@ -6389,7 +6397,7 @@ export class CodexPluginController { await responders.clear().catch(() => undefined); } const currentBinding = this.store.getBinding(callback.conversation); - const selectedEndpointId = callback.endpointId ?? this.getSelectedEndpointId(callback.conversation, currentBinding); + const selectedEndpointId = callback.endpointId ?? await this.getSelectedEndpointId(callback.conversation, currentBinding); const profile = this.resolveRequestedPermissionsMode( this.getPermissionsMode(currentBinding), callback.requestedYolo, @@ -7036,7 +7044,7 @@ export class CodexPluginController { : Promise.resolve({ text: this.formatEndpointListText({ conversation, - selection: this.getSelectedEndpointResolution(conversation), + selection: await this.getSelectedEndpointResolution(conversation), binding, }), buttons: undefined, @@ -7114,7 +7122,7 @@ export class CodexPluginController { await this.setSelectedEndpointId(conversation, callback.endpointId); const refreshedBinding = this.store.getBinding(callback.conversation); const text = this.buildEndpointSelectionNotice( - this.getSelectedEndpointResolution(conversation), + await this.getSelectedEndpointResolution(conversation), refreshedBinding, conversation, ); @@ -7164,7 +7172,7 @@ export class CodexPluginController { await this.clearSelectedEndpointId(conversation); const refreshedBinding = this.store.getBinding(callback.conversation); const text = this.buildEndpointSelectionNotice( - this.getSelectedEndpointResolution(conversation), + await this.getSelectedEndpointResolution(conversation), refreshedBinding, conversation, ); @@ -7400,7 +7408,7 @@ export class CodexPluginController { this.getPermissionsMode(binding), overrides.requestedYolo, ); - const resolvedEndpointId = endpointId ?? this.getSelectedEndpointId(conversation, binding); + const resolvedEndpointId = endpointId ?? await this.getSelectedEndpointId(conversation, binding); const created = await this.getClientForEndpoint(resolvedEndpointId).startThread({ profile, sessionKey: binding?.sessionKey, @@ -7878,7 +7886,7 @@ export class CodexPluginController { binding: StoredBinding | null, bindingActive: boolean, ): Promise { - const selection = this.getSelectedEndpointResolution(conversation); + const selection = await this.getSelectedEndpointResolution(conversation); const selectedEndpointId = selection.endpointId; const activeRun = bindingActive && conversation From 22fd11a73e8991ce97cbe9647132df2e251f8db8 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Fri, 1 May 2026 06:10:41 +0000 Subject: [PATCH 14/19] fix: probe paired node ip for derived CAS endpoints --- src/controller.test.ts | 66 +++++++++++++++++++++++++++++- src/controller.ts | 93 +++++++++++++++++++++++++++++++----------- 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 204ea91..622ea51 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, PluginCommandContext, ReplyPayload } from "openclaw/plugin-sdk"; -import { CodexAppServerClient } from "./client.js"; +import { CodexAppServerClient, CodexAppServerModeClient } from "./client.js"; import { CodexPluginController } from "./controller.js"; const TEST_TELEGRAM_PEER_ID = "telegram-user-1"; @@ -7405,6 +7405,70 @@ describe("Discord controller flows", () => { expect(deriveSpy).toHaveBeenCalledWith({ host: "node", node: "nestdev" }); }); + it("prefers the paired node remoteIp before falling back to the node name for derived probes", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + ], + }); + vi.spyOn(controller as any, "lookupNodeAddress").mockResolvedValue("172.23.100.26"); + + await expect((controller as any).resolveNodeProbeHosts("nestdev")).resolves.toEqual([ + "172.23.100.26", + "nestdev", + ]); + }); + + it("registers a derived node endpoint using the resolved remoteIp when the default app-server port responds", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + ], + }); + vi.spyOn(controller as any, "resolveNodeProbeHosts").mockResolvedValue([ + "172.23.100.26", + "nestdev", + ]); + const readSpy = vi.spyOn(CodexAppServerModeClient.prototype, "readAccount").mockImplementation(async function (this: CodexAppServerModeClient) { + const url = ((this as any).clients?.default as any)?.settings?.url; + if (url === "ws://172.23.100.26:8765") { + return { + loginState: "logged-in", + endpointId: "probe", + } as any; + } + throw new Error(`unexpected probe url ${String(url)}`); + }); + const closeSpy = vi.spyOn(CodexAppServerModeClient.prototype, "close").mockResolvedValue(); + + await expect( + (controller as any).tryRegisterNodeDerivedEndpoint({ + host: "node", + node: "nestdev", + }), + ).resolves.toBe("auto-node-nestdev"); + + expect(readSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect((controller as any).settings.endpoints).toContainEqual( + expect.objectContaining({ + id: "auto-node-nestdev", + url: "ws://172.23.100.26:8765", + execNodes: expect.arrayContaining(["nestdev", "172.23.100.26"]), + }), + ); + }); + it("uses the derived node endpoint in /cas_resume when no configured match exists", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", diff --git a/src/controller.ts b/src/controller.ts index c901e69..2823404 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1864,35 +1864,43 @@ export class CodexPluginController { return existingById.id; } - const derivedUrl = this.buildNodeDerivedEndpointUrl(node); - const probeEndpoint: EndpointSettings = { - id: `${derivedEndpointId}__probe`, - execNodes: [node], - transport: "websocket", - command: "codex", - args: [], - url: derivedUrl, - requestTimeoutMs: 3_000, - }; - const probeClient = new CodexAppServerModeClient(probeEndpoint, this.api.logger); - let available = false; - try { - await probeClient.readAccount({ profile: "default" }); - available = true; - } catch (error) { - this.api.logger.debug?.( - `codex auto-node endpoint probe failed node=${node} url=${derivedUrl}: ${String(error)}`, - ); - } finally { - await probeClient.close().catch(() => undefined); + const probeHosts = await this.resolveNodeProbeHosts(node); + let derivedUrl: string | undefined; + for (const probeHost of probeHosts) { + const candidateUrl = this.buildNodeDerivedEndpointUrl(probeHost); + const probeEndpoint: EndpointSettings = { + id: `${derivedEndpointId}__probe`, + execNodes: [node], + transport: "websocket", + command: "codex", + args: [], + url: candidateUrl, + requestTimeoutMs: 3_000, + }; + const probeClient = new CodexAppServerModeClient(probeEndpoint, this.api.logger); + let available = false; + try { + await probeClient.readAccount({ profile: "default" }); + available = true; + } catch (error) { + this.api.logger.debug?.( + `codex auto-node endpoint probe failed node=${node} host=${probeHost} url=${candidateUrl}: ${String(error)}`, + ); + } finally { + await probeClient.close().catch(() => undefined); + } + if (available) { + derivedUrl = candidateUrl; + break; + } } - if (!available) { + if (!derivedUrl) { return undefined; } const derivedEndpoint: EndpointSettings = { id: derivedEndpointId, - execNodes: [node], + execNodes: [...new Set([node, ...probeHosts])], transport: "websocket", command: "codex", args: [], @@ -1906,6 +1914,45 @@ export class CodexPluginController { return derivedEndpoint.id; } + private async resolveNodeProbeHosts(node: string): Promise { + const candidates: string[] = []; + const pushCandidate = (value?: string | null) => { + const trimmed = value?.trim(); + if (!trimmed) { + return; + } + if (!candidates.some((entry) => entry.toLowerCase() === trimmed.toLowerCase())) { + candidates.push(trimmed); + } + }; + + const nodeAddress = await this.lookupNodeAddress(node).catch((error) => { + this.api.logger.debug?.(`codex auto-node address lookup failed node=${node}: ${String(error)}`); + return undefined; + }); + pushCandidate(nodeAddress); + pushCandidate(node); + return candidates; + } + + private async lookupNodeAddress(node: string): Promise { + const result = await execFileAsync("openclaw", ["nodes", "status", "--json"], { + timeout: 5_000, + maxBuffer: 1024 * 1024, + }); + const parsed = JSON.parse(result.stdout) as { + nodes?: Array<{ nodeId?: string; displayName?: string; remoteIp?: string; connected?: boolean }>; + }; + const normalizedNode = node.trim().toLowerCase(); + const match = parsed.nodes?.find((entry) => { + const nodeId = entry.nodeId?.trim().toLowerCase(); + const displayName = entry.displayName?.trim().toLowerCase(); + return nodeId === normalizedNode || displayName === normalizedNode; + }); + const remoteIp = match?.remoteIp?.trim(); + return remoteIp || undefined; + } + private buildNodeDerivedEndpointId(node: string): string { const normalized = node .trim() From f324dce097fe2ba810c18dd9ca1288e89b948bdc Mon Sep 17 00:00:00 2001 From: Yehonal Date: Fri, 1 May 2026 06:28:26 +0000 Subject: [PATCH 15/19] fix: honor auto exec host for derived CAS endpoints --- src/controller.test.ts | 21 +++++++++++++++++++++ src/controller.ts | 35 +++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/controller.test.ts b/src/controller.test.ts index 622ea51..72e8a97 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -7381,6 +7381,27 @@ describe("Discord controller flows", () => { expect((controller as any).resolveAgentEndpointId(undefined, { host: "gateway", node: "nestdev" })).toBe("default"); }); + it("auto-selects the matching endpoint when exec host=auto and node matches an endpoint alias", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev-cas", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + expect((controller as any).resolveAgentEndpointId(undefined, { host: "auto", node: "nestdev" })).toBe("nestdev-cas"); + }); + it("falls back to a derived node endpoint when exec host=node has no configured match", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", diff --git a/src/controller.ts b/src/controller.ts index 2823404..8e76268 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1848,29 +1848,33 @@ export class CodexPluginController { ): Promise { const host = execContext?.host?.trim().toLowerCase(); const node = execContext?.node?.trim(); - if (host !== "node" || !node) { + if (!this.shouldUseNodeDerivedEndpointResolution(host, node)) { return undefined; } - const normalizedNode = node.toLowerCase(); + const resolvedNode = node?.trim(); + if (!resolvedNode) { + return undefined; + } + const normalizedNode = resolvedNode.toLowerCase(); const existingAliasMatch = this.settings.endpoints.find((entry) => (entry.execNodes ?? []).some((alias) => alias.trim().toLowerCase() === normalizedNode), ); if (existingAliasMatch?.id) { return existingAliasMatch.id; } - const derivedEndpointId = this.buildNodeDerivedEndpointId(node); + const derivedEndpointId = this.buildNodeDerivedEndpointId(resolvedNode); const existingById = this.settings.endpoints.find((entry) => entry.id === derivedEndpointId); if (existingById?.id) { return existingById.id; } - const probeHosts = await this.resolveNodeProbeHosts(node); + const probeHosts = await this.resolveNodeProbeHosts(resolvedNode); let derivedUrl: string | undefined; for (const probeHost of probeHosts) { const candidateUrl = this.buildNodeDerivedEndpointUrl(probeHost); const probeEndpoint: EndpointSettings = { id: `${derivedEndpointId}__probe`, - execNodes: [node], + execNodes: [resolvedNode], transport: "websocket", command: "codex", args: [], @@ -1884,7 +1888,7 @@ export class CodexPluginController { available = true; } catch (error) { this.api.logger.debug?.( - `codex auto-node endpoint probe failed node=${node} host=${probeHost} url=${candidateUrl}: ${String(error)}`, + `codex auto-node endpoint probe failed node=${resolvedNode} host=${probeHost} url=${candidateUrl}: ${String(error)}`, ); } finally { await probeClient.close().catch(() => undefined); @@ -1900,7 +1904,7 @@ export class CodexPluginController { const derivedEndpoint: EndpointSettings = { id: derivedEndpointId, - execNodes: [...new Set([node, ...probeHosts])], + execNodes: [...new Set([resolvedNode, ...probeHosts])], transport: "websocket", command: "codex", args: [], @@ -1909,7 +1913,7 @@ export class CodexPluginController { }; this.settings.endpoints.push(derivedEndpoint); this.api.logger.info( - `codex auto-node endpoint registered id=${derivedEndpoint.id} node=${node} url=${derivedUrl}`, + `codex auto-node endpoint registered id=${derivedEndpoint.id} node=${resolvedNode} url=${derivedUrl}`, ); return derivedEndpoint.id; } @@ -1987,14 +1991,14 @@ export class CodexPluginController { private resolveEndpointIdFromExecContext(execContext?: AgentExecContext): string | undefined { const host = execContext?.host?.trim().toLowerCase(); - if (host !== "node") { + const node = execContext?.node?.trim(); + if (!this.shouldUseNodeDerivedEndpointResolution(host, node)) { return undefined; } - const node = execContext?.node?.trim(); - if (!node) { + const normalizedNode = node?.trim().toLowerCase(); + if (!normalizedNode) { return undefined; } - const normalizedNode = node.toLowerCase(); const exactMatch = this.settings.endpoints.find((entry) => entry.id?.trim().toLowerCase() === normalizedNode); if (exactMatch?.id) { return exactMatch.id; @@ -2005,6 +2009,13 @@ export class CodexPluginController { return aliasMatch?.id; } + private shouldUseNodeDerivedEndpointResolution(host?: string, node?: string): boolean { + if (!node?.trim()) { + return false; + } + return host === "node" || host === "auto"; + } + private resolveAgentPermissionsMode( endpointId: string, requested?: PermissionsMode, From 0e9ed17e15d7f8a39928fd61c2af5c41d7779bd1 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 7 May 2026 14:49:33 +0000 Subject: [PATCH 16/19] fix: resolve CAS endpoint from agent exec context --- src/client.ts | 5 + src/controller.test.ts | 59 ++++++++++ src/controller.ts | 250 +++++++++++++++++++++++++++++++++-------- 3 files changed, 270 insertions(+), 44 deletions(-) diff --git a/src/client.ts b/src/client.ts index a659cde..b65d380 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2420,6 +2420,11 @@ export function isMissingThreadError(error: unknown): boolean { } function buildFullAccessPluginSettings(settings: ClientEndpointSettings): ClientEndpointSettings | null { + if (settings.transport === "websocket") { + return { + ...settings, + }; + } if (settings.transport !== "stdio") { return null; } diff --git a/src/controller.test.ts b/src/controller.test.ts index 72e8a97..f36ebc6 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -7535,6 +7535,65 @@ describe("Discord controller flows", () => { expect(reply.text).toContain("Resolved endpoint: auto-node-nestdev (auto from node: nestdev)"); }); + it("resolves /cas_resume endpoint from the active agent tools.exec config", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + const listSpy = vi.spyOn(controller as any, "handleListCommand").mockResolvedValue({ + text: "picker body", + }); + + const reply = await controller.handleCommand( + "cas_resume", + buildDiscordCommandContext({ + sessionKey: "agent:karan-nestdev:discord:channel:1501285305729155143", + config: { + tools: { + exec: { + host: "gateway", + node: "nestdev", + }, + }, + agents: { + list: [ + { + id: "karan-nestdev", + tools: { + exec: { + host: "node", + node: "nestdev", + }, + }, + }, + ], + }, + }, + }), + ); + + expect(listSpy).toHaveBeenCalledWith( + expect.anything(), + null, + "nestdev", + "", + "discord", + ); + expect(reply.text).toContain("Resolved endpoint: nestdev (auto from node: nestdev)"); + }); + it("falls back to default endpoint when node-derived probe is unavailable", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", diff --git a/src/controller.ts b/src/controller.ts index 8e76268..818fb7f 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -17,6 +17,7 @@ import type { ConversationRef, } from "openclaw/plugin-sdk"; import { getCurrentPluginConversationBinding } from "openclaw/plugin-sdk/conversation-runtime"; +import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { resolvePluginSettings, resolveWorkspaceDir } from "./config.js"; import { CodexAppServerModeClient, type ActiveCodexRun, isMissingThreadError } from "./client.js"; import { getThreadDisplayTitle } from "./thread-display.js"; @@ -1512,6 +1513,7 @@ export class CodexPluginController { private readonly clients = new Map(); private readonly activeRuns = new Map(); private readonly threadChangesCache = new Map>(); + private readonly conversationSessionKeys = new Map(); private readonly store; private serviceWorkspaceDir?: string; private lastRuntimeConfig?: unknown; @@ -2059,6 +2061,101 @@ export class CodexPluginController { return { host, node }; } + private readExecContextFromSession( + config: unknown, + sessionKey: string | undefined, + ): AgentExecContext | undefined { + const resolvedSessionKey = sessionKey?.trim(); + if (!resolvedSessionKey || !config || typeof config !== "object" || Array.isArray(config)) { + return undefined; + } + const sessionConfig = (config as { session?: unknown }).session; + const storeSetting = + sessionConfig && typeof sessionConfig === "object" && !Array.isArray(sessionConfig) + ? (sessionConfig as { store?: unknown }).store + : undefined; + const storePath = resolveStorePath(typeof storeSetting === "string" ? storeSetting : undefined); + const entry = loadSessionStore(storePath, { skipCache: true })[resolvedSessionKey]; + const host = typeof entry?.execHost === "string" ? entry.execHost.trim() : undefined; + const node = typeof entry?.execNode === "string" ? entry.execNode.trim() : undefined; + if (!host && !node) { + return undefined; + } + return { host, node }; + } + + private readExecContextFromAgentSessionKey( + config: unknown, + sessionKey: string | undefined, + ): AgentExecContext | undefined { + const agentId = sessionKey?.trim().match(/^agent:([^:]+):/)?.[1]?.trim(); + if (!agentId || !config || typeof config !== "object" || Array.isArray(config)) { + return undefined; + } + const agents = (config as { agents?: unknown }).agents; + const list = agents && typeof agents === "object" && !Array.isArray(agents) + ? (agents as { list?: unknown }).list + : undefined; + if (!Array.isArray(list)) { + return undefined; + } + const agent = list.find((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + const id = typeof (entry as { id?: unknown }).id === "string" + ? (entry as { id?: string }).id?.trim() + : undefined; + return id === agentId; + }); + if (!agent || typeof agent !== "object" || Array.isArray(agent)) { + return undefined; + } + const tools = (agent as { tools?: unknown }).tools; + if (!tools || typeof tools !== "object" || Array.isArray(tools)) { + return undefined; + } + const exec = (tools as { exec?: unknown }).exec; + if (!exec || typeof exec !== "object" || Array.isArray(exec)) { + return undefined; + } + const host = typeof (exec as { host?: unknown }).host === "string" + ? (exec as { host?: string }).host?.trim() + : undefined; + const node = typeof (exec as { node?: unknown }).node === "string" + ? (exec as { node?: string }).node?.trim() + : undefined; + if (!host && !node) { + return undefined; + } + return { host, node }; + } + + private resolveConversationSessionKey( + conversation: ConversationTarget | null | undefined, + sessionKey?: string, + ): string | undefined { + const explicit = sessionKey?.trim(); + if (explicit) { + return explicit; + } + if (!conversation) { + return undefined; + } + return this.conversationSessionKeys.get(buildConversationKey(conversation)); + } + + private resolvePreferredExecContext( + conversation: ConversationTarget | null | undefined, + sessionKey?: string, + ): AgentExecContext | undefined { + const config = this.getOpenClawConfig(); + const resolvedSessionKey = this.resolveConversationSessionKey(conversation, sessionKey); + return this.readExecContextFromSession(config, resolvedSessionKey) + ?? this.readExecContextFromAgentSessionKey(config, resolvedSessionKey) + ?? this.readExecContextFromConfig(config); + } + private getManualEndpointId(conversation: ConversationTarget | null | undefined): string | undefined { if (!conversation) { return undefined; @@ -2075,12 +2172,14 @@ export class CodexPluginController { private async getSelectedEndpointId( conversation: ConversationTarget | null | undefined, _binding?: StoredBinding | StoredPendingBind | null, + sessionKey?: string, ): Promise { - return (await this.getSelectedEndpointResolution(conversation)).endpointId; + return (await this.getSelectedEndpointResolution(conversation, sessionKey)).endpointId; } private async getSelectedEndpointResolution( conversation: ConversationTarget | null | undefined, + sessionKey?: string, ): Promise { const manualEndpointId = this.getManualEndpointId(conversation); if (manualEndpointId) { @@ -2089,7 +2188,7 @@ export class CodexPluginController { source: "manual", }; } - const execContext = this.readExecContextFromConfig(this.getOpenClawConfig()); + const execContext = this.resolvePreferredExecContext(conversation, sessionKey); const autoEndpointId = this.resolveEndpointIdFromExecContext(execContext); if (autoEndpointId) { return { @@ -2114,6 +2213,7 @@ export class CodexPluginController { private async getSelectedEndpointResolutionWithNodeFallback( conversation: ConversationTarget | null | undefined, + sessionKey?: string, ): Promise { const manualEndpointId = this.getManualEndpointId(conversation); if (manualEndpointId) { @@ -2122,7 +2222,7 @@ export class CodexPluginController { source: "manual", }; } - const execContext = this.readExecContextFromConfig(this.getOpenClawConfig()); + const execContext = this.resolvePreferredExecContext(conversation, sessionKey); const autoEndpointId = this.resolveEndpointIdFromExecContext(execContext); if (autoEndpointId) { return { @@ -2451,6 +2551,39 @@ export class CodexPluginController { } } + private resolveBoundConversationScope(conversation: ConversationTarget): { + conversation: ConversationTarget; + binding: StoredBinding | null; + } { + const exact = this.store.getBinding(conversation); + if (exact) { + return { conversation, binding: exact }; + } + const scoped = this.store.listBindingsForConversationScope(conversation); + if (scoped.length === 0) { + return { conversation, binding: null }; + } + const normalizedThreadConversationId = + isDiscordChannel(conversation.channel) && conversation.threadId != null + ? normalizeDiscordChannelConversationId(String(conversation.threadId)) + : undefined; + const preferred = + (normalizedThreadConversationId + ? scoped.find((entry) => entry.conversation.conversationId === normalizedThreadConversationId) + : undefined) ?? (scoped.length === 1 ? scoped[0] : null); + if (!preferred) { + return { conversation, binding: null }; + } + return { + conversation: { + ...conversation, + conversationId: preferred.conversation.conversationId, + parentConversationId: preferred.conversation.parentConversationId, + }, + binding: preferred, + }; + } + async handleInboundClaim(event: { content: string; channel: string; @@ -2467,10 +2600,12 @@ export class CodexPluginController { return { handled: false }; } await this.start(); - const conversation = toConversationTargetFromInbound(event); - if (!conversation) { + const inboundConversation = toConversationTargetFromInbound(event); + if (!inboundConversation) { return { handled: false }; } + const scopedResolution = this.resolveBoundConversationScope(inboundConversation); + const conversation = scopedResolution.conversation; const input = await buildInboundTurnInput({ ...event, transcribeAudio: async (media) => await this.transcribeInboundAudio(media), @@ -2521,7 +2656,7 @@ export class CodexPluginController { await active.handle.interrupt().catch(() => undefined); } } - const existingBinding = this.store.getBinding(conversation); + const existingBinding = scopedResolution.binding ?? this.store.getBinding(conversation); const hydratedBinding = existingBinding ? null : await this.hydrateApprovedBinding(conversation); const recoveredBinding = existingBinding || hydratedBinding?.binding @@ -2858,6 +2993,10 @@ export class CodexPluginController { this.lastRuntimeConfig = ctx.config; const bindingApi = asScopedBindingApi(ctx); const conversation = toConversationTargetFromCommand(ctx); + const commandSessionKey = (ctx as PluginCommandContext & { sessionKey?: string }).sessionKey?.trim() || undefined; + if (conversation && commandSessionKey) { + this.conversationSessionKeys.set(buildConversationKey(conversation), commandSessionKey); + } const currentBinding = conversation && bindingApi.getCurrentConversationBinding ? await bindingApi.getCurrentConversationBinding() @@ -2883,7 +3022,7 @@ export class CodexPluginController { switch (commandName) { case "cas_resume": { const resolvedEndpoint = conversation - ? await this.getSelectedEndpointResolutionWithNodeFallback(conversation) + ? await this.getSelectedEndpointResolutionWithNodeFallback(conversation, commandSessionKey) : undefined; const resolvedEndpointText = resolvedEndpoint ? `Resolved endpoint: ${this.formatEndpointResolutionLabel(resolvedEndpoint)}` @@ -2940,6 +3079,7 @@ export class CodexPluginController { binding, args, Boolean(currentBinding || binding), + commandSessionKey, ); case "cas_stop": return await this.handleStopCommand(conversation); @@ -2960,15 +3100,16 @@ export class CodexPluginController { case "cas_fast": return await this.handleFastCommand(binding, args); case "cas_model": - return await this.handleModelCommand(conversation, binding, args); + return await this.handleModelCommand(conversation, binding, args, commandSessionKey); case "cas_endpoints": case "cas_endpoint": - return await this.handleEndpointCommand(conversation, binding, args); + return await this.handleEndpointCommand(conversation, binding, args, commandSessionKey); case "cas_permissions": return await this.handlePermissionsCommand( conversation, binding, Boolean(currentBinding || binding), + commandSessionKey, ); case "cas_init": return await this.handlePromptAlias(conversation, binding, args, "/init"); @@ -3092,7 +3233,11 @@ export class CodexPluginController { if (parsed.error) { return { text: parsed.error }; } - const selectedEndpoint = await this.getSelectedEndpointResolutionWithNodeFallback(conversation); + const commandSessionKey = (ctx as PluginCommandContext & { sessionKey?: string }).sessionKey?.trim() || undefined; + const selectedEndpoint = await this.getSelectedEndpointResolutionWithNodeFallback( + conversation, + commandSessionKey, + ); const selectedEndpointId = selectedEndpoint.endpointId; const resumeBinding = binding && this.getEndpointIdForBinding(binding) === selectedEndpointId ? binding : null; @@ -3280,6 +3425,7 @@ export class CodexPluginController { binding: StoredBinding | null, args: string, bindingActive: boolean, + sessionKey?: string, ): Promise { const parsed = parseStatusArgs(args); if (parsed.error) { @@ -3311,7 +3457,7 @@ export class CodexPluginController { ); if (targetPermissionsMode === "full-access" && !this.hasFullAccessProfile(binding)) { note = buildPermissionsUnavailableNote(); - const card = await this.buildStatusCard(conversation, binding, bindingActive); + const card = await this.buildStatusCard(conversation, binding, bindingActive, sessionKey); const text = `${card.text}\n\n${note}`; if (!card.buttons || !conversation) { return { text }; @@ -3351,7 +3497,7 @@ export class CodexPluginController { note = buildPendingPermissionsMigrationNote(targetPermissionsMode); } } - const card = await this.buildStatusCard(conversation, binding, bindingActive); + const card = await this.buildStatusCard(conversation, binding, bindingActive, sessionKey); const text = note ? `${card.text}\n\n${note}` : card.text; if (!card.buttons || !conversation) { return { text }; @@ -3933,8 +4079,9 @@ export class CodexPluginController { conversation: ConversationTarget | null, binding: StoredBinding | null, bindingActive: boolean, + sessionKey?: string, ): Promise { - const text = await this.buildStatusText(conversation, binding, bindingActive); + const text = await this.buildStatusText(conversation, binding, bindingActive, sessionKey); if (!conversation || !binding || !bindingActive) { return { text }; } @@ -4377,12 +4524,13 @@ export class CodexPluginController { conversation: ConversationTarget | null, binding: StoredBinding | null, args: string, + sessionKey?: string, ): Promise { const trimmedArgs = args.trim(); const profile = this.getPermissionsMode(binding); if (!binding) { const models = await this.getClientForEndpoint( - await this.getSelectedEndpointId(conversation, binding), + await this.getSelectedEndpointId(conversation, binding, sessionKey), ).listModels({ profile }); return { text: formatModels(models) }; } @@ -4450,50 +4598,63 @@ export class CodexPluginController { conversation: ConversationTarget | null, binding: StoredBinding | null, args: string, + sessionKey?: string, ): Promise { if (!conversation) { return { text: "This command needs a Telegram or Discord conversation." }; } - const parsed = parseEndpointArgs(args); - if (parsed.error) { - return { text: parsed.error }; - } - const currentSelection = await this.getSelectedEndpointResolution(conversation); - if (!parsed.endpointId) { - const picker = await this.buildEndpointPicker(conversation, binding); - return buildReplyWithButtons(picker.text, picker.buttons); - } - const requested = parsed.endpointId.trim(); - if (["auto", "clear"].includes(requested.toLowerCase())) { - await this.clearSelectedEndpointId(conversation); - const nextSelection = await this.getSelectedEndpointResolution(conversation); + try { + const parsed = parseEndpointArgs(args); + if (parsed.error) { + return { text: parsed.error }; + } + const currentSelection = await this.getSelectedEndpointResolution(conversation, sessionKey); + if (!parsed.endpointId) { + const picker = await this.buildEndpointPicker(conversation, binding); + return buildReplyWithButtons(picker.text, picker.buttons); + } + const requested = parsed.endpointId.trim(); + if (["auto", "clear"].includes(requested.toLowerCase())) { + await this.clearSelectedEndpointId(conversation); + const nextSelection = await this.getSelectedEndpointResolution(conversation, sessionKey); + return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; + } + const endpoint = this.settings.endpoints.find((entry) => entry.id === requested); + if (!endpoint) { + return { + text: [ + `Unknown endpoint: ${requested}`, + "", + this.formatEndpointListText({ + conversation, + selection: currentSelection, + binding, + }), + ].join("\n"), + }; + } + await this.setSelectedEndpointId(conversation, endpoint.id || requested); + const nextSelection = await this.getSelectedEndpointResolution(conversation, sessionKey); return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; - } - const endpoint = this.settings.endpoints.find((entry) => entry.id === requested); - if (!endpoint) { + } catch (error) { + const detail = error instanceof Error ? error.stack ?? error.message : String(error); + this.api.logger.warn( + `codex endpoint command failed conversation=${conversation.conversationId} sessionKey=${sessionKey ?? ""}: ${detail}`, + ); return { - text: [ - `Unknown endpoint: ${requested}`, - "", - this.formatEndpointListText({ - conversation, - selection: currentSelection, - binding, - }), - ].join("\n"), + text: + "cas_endpoint failed internally. Check gateway logs for `codex endpoint command failed` and retry.", }; } - await this.setSelectedEndpointId(conversation, endpoint.id || requested); - const nextSelection = await this.getSelectedEndpointResolution(conversation); - return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; } private async handlePermissionsCommand( conversation: ConversationTarget | null, binding: StoredBinding | null, bindingActive: boolean, + sessionKey?: string, ): Promise { - return await this.handleStatusCommand(conversation, binding, "", bindingActive); + return await this.handleStatusCommand(conversation, binding, "", bindingActive, sessionKey); } private async handlePromptAlias( @@ -7943,8 +8104,9 @@ export class CodexPluginController { conversation: ConversationTarget | null, binding: StoredBinding | null, bindingActive: boolean, + sessionKey?: string, ): Promise { - const selection = await this.getSelectedEndpointResolution(conversation); + const selection = await this.getSelectedEndpointResolution(conversation, sessionKey); const selectedEndpointId = selection.endpointId; const activeRun = bindingActive && conversation From aa9c089b540aed6518b6907c6b1cc51c2f2a0043 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Thu, 7 May 2026 16:12:16 +0000 Subject: [PATCH 17/19] feat: allow default reasoning effort --- openclaw.plugin.json | 13 +++++++++++++ src/config.ts | 1 + src/controller.ts | 9 +++++++-- src/types.ts | 1 + 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/openclaw.plugin.json b/openclaw.plugin.json index b0514b4..f7a14c2 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -110,6 +110,15 @@ "defaultModel": { "type": "string" }, + "defaultReasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, "defaultServiceTier": { "type": "string" }, @@ -190,6 +199,10 @@ "label": "Default Model", "advanced": true }, + "defaultReasoningEffort": { + "label": "Default Reasoning Effort", + "advanced": true + }, "defaultServiceTier": { "label": "Default Service Tier", "advanced": true diff --git a/src/config.ts b/src/config.ts index 9f0b3e9..1df0004 100644 --- a/src/config.ts +++ b/src/config.ts @@ -154,6 +154,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { endpoints, defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"), defaultModel: readString(record, "defaultModel"), + defaultReasoningEffort: readString(record, "defaultReasoningEffort"), defaultServiceTier: readString(record, "defaultServiceTier"), inboundAudioTranscription: resolveInboundAudioTranscription(record), }; diff --git a/src/controller.ts b/src/controller.ts index 818fb7f..0b873ab 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1137,13 +1137,15 @@ function buildDesiredThreadConfiguration( threadState: ThreadState | undefined, binding: StoredBinding | null, modelFallback?: string, + reasoningEffortFallback?: string, ): DesiredThreadConfiguration { const effectiveState = applyBindingPreferencesToThreadState(threadState, binding) ?? threadState; const model = effectiveState?.model?.trim() || modelFallback; return { effectiveState, model, - reasoningEffort: normalizeReasoningEffort(effectiveState?.reasoningEffort), + reasoningEffort: normalizeReasoningEffort(effectiveState?.reasoningEffort) + ?? normalizeReasoningEffort(reasoningEffortFallback), serviceTier: modelSupportsFast(model) ? requestServiceTierFromPreference(effectiveState?.serviceTier) : null, @@ -1775,7 +1777,7 @@ export class CodexPluginController { runId: `agent-${crypto.randomUUID()}`, existingThreadId: threadId || undefined, model: params.model?.trim() || this.settings.defaultModel, - reasoningEffort: params.reasoningEffort?.trim() || undefined, + reasoningEffort: params.reasoningEffort?.trim() || this.settings.defaultReasoningEffort, serviceTier: params.serviceTier?.trim() || this.settings.defaultServiceTier, collaborationMode: params.collaborationMode, onPendingInput: async (state) => { @@ -4877,6 +4879,7 @@ export class CodexPluginController { undefined, params.binding, this.settings.defaultModel, + this.settings.defaultReasoningEffort, ); const run = this.getClientForBinding(params.binding).startTurn({ profile, @@ -5129,6 +5132,7 @@ export class CodexPluginController { threadState ?? undefined, params.binding, this.settings.defaultModel, + this.settings.defaultReasoningEffort, ); const effectiveThreadState = desired.effectiveState; const run = this.getClientForBinding(params.binding).startTurn({ @@ -5319,6 +5323,7 @@ export class CodexPluginController { threadState ?? undefined, params.binding, this.settings.defaultModel, + this.settings.defaultReasoningEffort, ); const run = this.getClientForBinding(params.binding).startReview({ profile, diff --git a/src/types.ts b/src/types.ts index 33978a3..d8ef922 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,7 @@ export type PluginSettings = { endpoints: EndpointSettings[]; defaultWorkspaceDir?: string; defaultModel?: string; + defaultReasoningEffort?: string; defaultServiceTier?: string; inboundAudioTranscription?: InboundAudioTranscriptionSettings; }; From 0ae865ae213aea4e3e799db66f01b50e0e4e24e6 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Sat, 9 May 2026 07:28:46 +0000 Subject: [PATCH 18/19] fix: support endpoint workspace defaults --- README.md | 1 + openclaw.plugin.json | 3 +++ src/config.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 4 ++++ src/controller.ts | 38 ++++++++++++++++++++++++++++--- src/types.ts | 1 + 6 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 src/config.test.ts diff --git a/README.md b/README.md index c97bb17..6a72937 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ The plugin schema in [`openclaw.plugin.json`](./openclaw.plugin.json) supports: - `execNodes`: optional list of `tools.exec.node` aliases that should auto-select a specific endpoint when agent tools run with `tools.exec.host=node` - `url`, `authToken`, `headers`: connection settings for `websocket` - `defaultWorkspaceDir`: fallback workspace for unbound actions +- `endpoints[].defaultWorkspaceDir`: endpoint-specific fallback workspace; useful when a remote app-server cannot access the controller host path - `defaultModel`: model used when a new thread starts without an explicit selection - `defaultServiceTier`: default service tier for new turns - `inboundAudioTranscription`: optional preprocessor for inbound audio/voice attachments before they are forwarded into Codex diff --git a/openclaw.plugin.json b/openclaw.plugin.json index f7a14c2..81c7279 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -96,6 +96,9 @@ "requestTimeoutMs": { "type": "number", "minimum": 100 + }, + "defaultWorkspaceDir": { + "type": "string" } } } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..1dd5c6b --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { resolvePluginSettings, resolveWorkspaceDir } from "./config.js"; + + +describe("config resolution", () => { + it("supports endpoint-specific default workspace directories", () => { + const settings = resolvePluginSettings({ + defaultWorkspaceDir: "/local/workspace", + endpoints: [ + { + id: "default", + transport: "stdio", + }, + { + id: "remote", + transport: "websocket", + url: "ws://remote:8765", + defaultWorkspaceDir: " /home/agent/workspace ", + }, + ], + }); + + expect(settings.defaultWorkspaceDir).toBe("/local/workspace"); + expect(settings.endpoints[1]?.defaultWorkspaceDir).toBe("/home/agent/workspace"); + }); + + it("prefers requested and binding workspaces before endpoint defaults", () => { + expect( + resolveWorkspaceDir({ + requested: " /requested ", + bindingWorkspaceDir: "/binding", + endpointWorkspaceDir: "/endpoint", + configuredWorkspaceDir: "/global", + }), + ).toBe("/requested"); + + expect( + resolveWorkspaceDir({ + bindingWorkspaceDir: " /binding ", + endpointWorkspaceDir: "/endpoint", + configuredWorkspaceDir: "/global", + }), + ).toBe("/binding"); + }); + + it("uses endpoint workspace defaults before global defaults", () => { + expect( + resolveWorkspaceDir({ + endpointWorkspaceDir: " /endpoint ", + configuredWorkspaceDir: "/global", + }), + ).toBe("/endpoint"); + }); +}); diff --git a/src/config.ts b/src/config.ts index 1df0004..95a06b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,6 +110,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { args: readStringArray(entry, "args"), url: readString(entry, "url"), headers: Object.keys(headers).length > 0 ? headers : undefined, + defaultWorkspaceDir: readString(entry, "defaultWorkspaceDir"), requestTimeoutMs: readNumber(entry, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS, 100), }; }; @@ -135,6 +136,7 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { args: readStringArray(record, "args"), url: readString(record, "url"), headers: Object.keys(legacyHeaders).length > 0 ? legacyHeaders : undefined, + defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"), requestTimeoutMs: readNumber( record, "requestTimeoutMs", @@ -163,12 +165,14 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { export function resolveWorkspaceDir(params: { requested?: string; bindingWorkspaceDir?: string; + endpointWorkspaceDir?: string; configuredWorkspaceDir?: string; serviceWorkspaceDir?: string; }): string { return ( params.requested?.trim() || params.bindingWorkspaceDir?.trim() || + params.endpointWorkspaceDir?.trim() || params.configuredWorkspaceDir?.trim() || params.serviceWorkspaceDir?.trim() || process.cwd() diff --git a/src/controller.ts b/src/controller.ts index 0b873ab..8204987 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1576,6 +1576,7 @@ export class CodexPluginController { url: string | null; command: string; args: string[]; + defaultWorkspaceDir: string | null; requestTimeoutMs: number; supportsFullAccess: boolean; }>; @@ -1592,6 +1593,7 @@ export class CodexPluginController { url: endpoint.url ?? null, command: endpoint.command, args: [...endpoint.args], + defaultWorkspaceDir: endpoint.defaultWorkspaceDir ?? null, requestTimeoutMs: endpoint.requestTimeoutMs, supportsFullAccess: this.getClientForEndpoint(endpoint.id).hasProfile("full-access"), })), @@ -1623,6 +1625,7 @@ export class CodexPluginController { ? undefined : resolveWorkspaceDir({ requested: params.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForEndpoint(endpointId), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -1717,6 +1720,7 @@ export class CodexPluginController { const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = resolveWorkspaceDir({ requested: params.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForEndpoint(endpointId), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -2039,6 +2043,27 @@ export class CodexPluginController { return this.settings.defaultEndpoint; } + private getEndpointSettings(endpointId?: string): EndpointSettings | undefined { + const resolvedEndpointId = + endpointId && this.settings.endpoints.some((entry) => entry.id === endpointId) + ? endpointId + : this.settings.defaultEndpoint; + return ( + this.settings.endpoints.find((entry) => entry.id === resolvedEndpointId) ?? + this.settings.endpoints[0] + ); + } + + private getConfiguredWorkspaceDirForEndpoint(endpointId?: string): string | undefined { + return this.getEndpointSettings(endpointId)?.defaultWorkspaceDir ?? this.settings.defaultWorkspaceDir; + } + + private getConfiguredWorkspaceDirForBinding( + binding: StoredBinding | StoredPendingBind | null | undefined, + ): string | undefined { + return this.getConfiguredWorkspaceDirForEndpoint(this.getEndpointIdForBinding(binding)); + } + private readExecContextFromConfig(config: unknown): AgentExecContext | undefined { if (!config || typeof config !== "object" || Array.isArray(config)) { return undefined; @@ -2340,9 +2365,7 @@ export class CodexPluginController { if (existing) { return existing; } - const endpoint = - this.settings.endpoints.find((entry) => entry.id === resolvedEndpointId) ?? - this.settings.endpoints[0]; + const endpoint = this.getEndpointSettings(resolvedEndpointId); if (!endpoint) { throw new Error("Codex endpoint configuration is missing."); } @@ -3393,6 +3416,7 @@ export class CodexPluginController { workspaceDir || resolveWorkspaceDir({ bindingWorkspaceDir: resumeBinding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForEndpoint(selectedEndpointId), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }), @@ -4104,6 +4128,7 @@ export class CodexPluginController { ): Promise { const workspaceDir = resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -4278,6 +4303,7 @@ export class CodexPluginController { } const workspaceDir = resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -4415,6 +4441,7 @@ export class CodexPluginController { ): Promise { const workspaceDir = resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -4670,6 +4697,7 @@ export class CodexPluginController { } const workspaceDir = resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -5688,6 +5716,7 @@ export class CodexPluginController { } return resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -6816,6 +6845,7 @@ export class CodexPluginController { }; const workspaceDir = callback.workspaceDir?.trim() || binding?.workspaceDir || resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -7184,6 +7214,7 @@ export class CodexPluginController { binding?.workspaceDir || resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); @@ -8121,6 +8152,7 @@ export class CodexPluginController { const pendingProfile = getBindingPendingPermissionsMode(binding); const workspaceDir = resolveWorkspaceDir({ bindingWorkspaceDir: binding?.workspaceDir, + endpointWorkspaceDir: this.getConfiguredWorkspaceDirForBinding(binding), configuredWorkspaceDir: this.settings.defaultWorkspaceDir, serviceWorkspaceDir: this.serviceWorkspaceDir, }); diff --git a/src/types.ts b/src/types.ts index d8ef922..d7476bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,7 @@ export type EndpointSettings = { args: string[]; url?: string; headers?: Record; + defaultWorkspaceDir?: string; requestTimeoutMs: number; }; From ad5670ea37097215b0422893a4ad9585ef05e2f5 Mon Sep 17 00:00:00 2001 From: Yehonal Date: Mon, 11 May 2026 12:36:30 +0000 Subject: [PATCH 19/19] Add per-agent default endpoint resolution --- README.md | 1 + docs/autonomous-worker-tools.md | 3 + openclaw.plugin.json | 10 ++ src/config.test.ts | 26 +++++ src/config.ts | 20 ++++ src/controller.test.ts | 180 ++++++++++++++++++++++++++++++++ src/controller.ts | 60 ++++++++++- src/types.ts | 1 + 8 files changed, 297 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6a72937..b609264 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ The plugin schema in [`openclaw.plugin.json`](./openclaw.plugin.json) supports: - `execNodes`: optional list of `tools.exec.node` aliases that should auto-select a specific endpoint when agent tools run with `tools.exec.host=node` - `url`, `authToken`, `headers`: connection settings for `websocket` - `defaultWorkspaceDir`: fallback workspace for unbound actions +- `agentEndpoints`: optional map of OpenClaw agent id to default endpoint id, used after manual `/cas_endpoint` overrides and exec node-derived endpoint selection but before `defaultEndpoint` - `endpoints[].defaultWorkspaceDir`: endpoint-specific fallback workspace; useful when a remote app-server cannot access the controller host path - `defaultModel`: model used when a new thread starts without an explicit selection - `defaultServiceTier`: default service tier for new turns diff --git a/docs/autonomous-worker-tools.md b/docs/autonomous-worker-tools.md index 3404ec3..2c7aa26 100644 --- a/docs/autonomous-worker-tools.md +++ b/docs/autonomous-worker-tools.md @@ -32,10 +32,13 @@ MCP is still useful **inside** Codex for tools, but for **OpenClaw -> Codex work Returns: - default endpoint +- per-agent default endpoint map - default workspace/model - configured endpoints - whether each endpoint supports `full-access` +Worker tools resolve endpoints in this order: explicit `endpointId`, exec-context/node-derived endpoint, `agentEndpoints[agentId]`, then `defaultEndpoint`. + ### `codex_workers_list_threads` Lists threads on an endpoint. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 81c7279..d6ac164 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -50,6 +50,12 @@ "defaultEndpoint": { "type": "string" }, + "agentEndpoints": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "endpoints": { "type": "array", "items": { @@ -187,6 +193,10 @@ "label": "Default Endpoint", "advanced": true }, + "agentEndpoints": { + "label": "Agent Endpoints", + "advanced": true + }, "endpoints": { "label": "Endpoints", "advanced": true diff --git a/src/config.test.ts b/src/config.test.ts index 1dd5c6b..9cec053 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -24,6 +24,32 @@ describe("config resolution", () => { expect(settings.endpoints[1]?.defaultWorkspaceDir).toBe("/home/agent/workspace"); }); + it("keeps only agent endpoint defaults that reference configured endpoints", () => { + const settings = resolvePluginSettings({ + defaultEndpoint: "default", + agentEndpoints: { + "karan-nestdev": "nestdev", + "unknown-agent": "missing", + }, + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + expect(settings.agentEndpoints).toEqual({ + "karan-nestdev": "nestdev", + }); + }); + it("prefers requested and binding workspaces before endpoint defaults", () => { expect( resolveWorkspaceDir({ diff --git a/src/config.ts b/src/config.ts index 95a06b1..94dc485 100644 --- a/src/config.ts +++ b/src/config.ts @@ -33,6 +33,19 @@ function readStringArray(record: Record, key: string): string[] .filter(Boolean); } +function readStringMap(record: Record, key: string): Record { + const value = record[key]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return Object.fromEntries( + Object.entries(value) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .map(([entryKey, entryValue]) => [entryKey.trim(), entryValue.trim()]) + .filter(([entryKey, entryValue]) => entryKey && entryValue), + ); +} + function readHeaders(record: Record): Record | undefined { const value = record.headers; if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -149,10 +162,17 @@ export function resolvePluginSettings(rawConfig: unknown): PluginSettings { const requestedDefaultEndpoint = readString(record, "defaultEndpoint"); const defaultEndpoint = endpoints.find((entry) => entry.id === requestedDefaultEndpoint)?.id ?? endpoints[0]?.id ?? "default"; + const endpointIds = new Set(endpoints.map((entry) => entry.id).filter(Boolean)); + const agentEndpoints = Object.fromEntries( + Object.entries(readStringMap(record, "agentEndpoints")) + .map(([agentId, endpointId]) => [agentId, endpoints.find((entry) => entry.id === endpointId)?.id] as const) + .filter((entry): entry is [string, string] => Boolean(entry[1]) && endpointIds.has(entry[1])), + ); return { enabled: record.enabled !== false, defaultEndpoint, + agentEndpoints, endpoints, defaultWorkspaceDir: readString(record, "defaultWorkspaceDir"), defaultModel: readString(record, "defaultModel"), diff --git a/src/controller.test.ts b/src/controller.test.ts index f36ebc6..75b9abe 100644 --- a/src/controller.test.ts +++ b/src/controller.test.ts @@ -7615,6 +7615,106 @@ describe("Discord controller flows", () => { ).resolves.toBe("default"); }); + it("prefers exec-context endpoint resolution over an agent default endpoint", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + agentEndpoints: { + "karan-nestdev": "agent-default", + }, + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "agent-default", + transport: "websocket", + url: "ws://127.0.0.1:8766", + }, + { + id: "nestdev", + execNodes: ["nestdev"], + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + await expect( + (controller as any).resolveAgentEndpointIdWithNodeFallback( + undefined, + { host: "node", node: "nestdev" }, + "agent:karan-nestdev:discord:channel:chan-1", + ), + ).resolves.toBe("nestdev"); + }); + + it("uses an agent default endpoint before the global default endpoint", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + agentEndpoints: { + "karan-nestdev": "nestdev", + }, + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + await expect( + (controller as any).resolveAgentEndpointIdWithNodeFallback( + undefined, + undefined, + "agent:karan-nestdev:discord:channel:chan-1", + ), + ).resolves.toBe("nestdev"); + }); + + it("uses an agent default endpoint for conversation selection before the global default endpoint", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + agentEndpoints: { + "karan-nestdev": "nestdev", + }, + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "nestdev", + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + await expect( + (controller as any).getSelectedEndpointResolution( + { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + "agent:karan-nestdev:discord:channel:chan-1", + ), + ).resolves.toMatchObject({ + endpointId: "nestdev", + source: "agent-default", + agentId: "karan-nestdev", + }); + }); + it("keeps explicit endpoint selection over node-derived fallback", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", @@ -7642,6 +7742,40 @@ describe("Discord controller flows", () => { expect(deriveSpy).not.toHaveBeenCalled(); }); + it("keeps explicit endpoint selection over an agent default endpoint", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + agentEndpoints: { + "karan-nestdev": "nestdev", + }, + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "gateway", + transport: "websocket", + url: "ws://127.0.0.1:9999", + }, + { + id: "nestdev", + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + + await expect( + (controller as any).resolveAgentEndpointIdWithNodeFallback( + "gateway", + undefined, + "agent:karan-nestdev:discord:channel:chan-1", + ), + ).resolves.toBe("gateway"); + }); + it("prefers a manual conversation endpoint over automatic node resolution", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", @@ -7686,6 +7820,52 @@ describe("Discord controller flows", () => { ).resolves.toMatchObject({ endpointId: "default", source: "manual" }); }); + it("prefers a manual conversation endpoint over an agent default endpoint", async () => { + const { controller } = await createControllerHarness({ + defaultEndpoint: "default", + agentEndpoints: { + "karan-nestdev": "nestdev", + }, + endpoints: [ + { + id: "default", + transport: "websocket", + url: "ws://127.0.0.1:8765", + }, + { + id: "manual", + transport: "websocket", + url: "ws://127.0.0.1:9999", + }, + { + id: "nestdev", + transport: "websocket", + url: "ws://172.23.100.26:8765", + }, + ], + }); + await (controller as any).store.upsertConversationEndpoint({ + conversation: { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + endpointId: "manual", + updatedAt: Date.now(), + }); + + await expect( + (controller as any).getSelectedEndpointResolution( + { + channel: "discord", + accountId: "default", + conversationId: "channel:chan-1", + }, + "agent:karan-nestdev:discord:channel:chan-1", + ), + ).resolves.toMatchObject({ endpointId: "manual", source: "manual" }); + }); + it("clears the manual endpoint override and falls back to automatic node resolution", async () => { const { controller } = await createControllerHarness({ defaultEndpoint: "default", diff --git a/src/controller.ts b/src/controller.ts index 8204987..8eaf9c0 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1455,8 +1455,9 @@ type AgentExecContext = { type EndpointResolution = { endpointId: string; - source: "manual" | "auto-node" | "default"; + source: "manual" | "auto-node" | "agent-default" | "default"; nodeId?: string; + agentId?: string; }; function listWorkspaceChoices( @@ -1567,6 +1568,7 @@ export class CodexPluginController { async describeAgentEndpoints(): Promise<{ defaultEndpoint: string; + agentEndpoints: Record; defaultWorkspaceDir: string | null; defaultModel: string | null; endpoints: Array<{ @@ -1584,6 +1586,7 @@ export class CodexPluginController { await this.start(); return { defaultEndpoint: this.settings.defaultEndpoint, + agentEndpoints: { ...this.settings.agentEndpoints }, defaultWorkspaceDir: this.settings.defaultWorkspaceDir ?? null, defaultModel: this.settings.defaultModel ?? null, endpoints: this.settings.endpoints.map((endpoint, index) => ({ @@ -1619,6 +1622,7 @@ export class CodexPluginController { const endpointId = await this.resolveAgentEndpointIdWithNodeFallback( params.endpointId, params.execContext, + params.sessionKey, ); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = params.includeAllWorkspaces @@ -1661,6 +1665,7 @@ export class CodexPluginController { const endpointId = await this.resolveAgentEndpointIdWithNodeFallback( params.endpointId, params.execContext, + params.sessionKey, ); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const threadId = params.threadId.trim(); @@ -1716,6 +1721,7 @@ export class CodexPluginController { const endpointId = await this.resolveAgentEndpointIdWithNodeFallback( params.endpointId, params.execContext, + params.sessionKey, ); const permissionsMode = this.resolveAgentPermissionsMode(endpointId, params.permissionsMode); const workspaceDir = resolveWorkspaceDir({ @@ -1817,7 +1823,11 @@ export class CodexPluginController { }; } - private resolveAgentEndpointId(endpointId?: string, execContext?: AgentExecContext): string { + private resolveAgentEndpointId( + endpointId?: string, + execContext?: AgentExecContext, + sessionKey?: string, + ): string { const requested = endpointId?.trim(); if (requested) { if (!this.settings.endpoints.some((entry) => entry.id === requested)) { @@ -1829,16 +1839,21 @@ export class CodexPluginController { if (inferred) { return inferred; } + const agentEndpointId = this.resolveEndpointIdFromAgentDefault(sessionKey); + if (agentEndpointId) { + return agentEndpointId; + } return this.settings.defaultEndpoint; } private async resolveAgentEndpointIdWithNodeFallback( endpointId?: string, execContext?: AgentExecContext, + sessionKey?: string, ): Promise { const requested = endpointId?.trim(); if (requested) { - return this.resolveAgentEndpointId(requested, execContext); + return this.resolveAgentEndpointId(requested, execContext, sessionKey); } const inferred = this.resolveEndpointIdFromExecContext(execContext); if (inferred) { @@ -1848,6 +1863,10 @@ export class CodexPluginController { if (derived) { return derived; } + const agentEndpointId = this.resolveEndpointIdFromAgentDefault(sessionKey); + if (agentEndpointId) { + return agentEndpointId; + } return this.settings.defaultEndpoint; } @@ -2115,7 +2134,7 @@ export class CodexPluginController { config: unknown, sessionKey: string | undefined, ): AgentExecContext | undefined { - const agentId = sessionKey?.trim().match(/^agent:([^:]+):/)?.[1]?.trim(); + const agentId = this.resolveAgentIdFromSessionKey(sessionKey); if (!agentId || !config || typeof config !== "object" || Array.isArray(config)) { return undefined; } @@ -2158,6 +2177,18 @@ export class CodexPluginController { return { host, node }; } + private resolveAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined { + return sessionKey?.trim().match(/^agent:([^:]+):/)?.[1]?.trim() || undefined; + } + + private resolveEndpointIdFromAgentDefault(sessionKey: string | undefined): string | undefined { + const agentId = this.resolveAgentIdFromSessionKey(sessionKey); + if (!agentId) { + return undefined; + } + return this.settings.agentEndpoints[agentId]?.trim() || undefined; + } + private resolveConversationSessionKey( conversation: ConversationTarget | null | undefined, sessionKey?: string, @@ -2232,6 +2263,15 @@ export class CodexPluginController { nodeId: execContext?.node?.trim() || undefined, }; } + const resolvedSessionKey = this.resolveConversationSessionKey(conversation, sessionKey); + const agentEndpointId = this.resolveEndpointIdFromAgentDefault(resolvedSessionKey); + if (agentEndpointId) { + return { + endpointId: agentEndpointId, + source: "agent-default", + agentId: this.resolveAgentIdFromSessionKey(resolvedSessionKey), + }; + } return { endpointId: this.settings.defaultEndpoint, source: "default", @@ -2266,6 +2306,15 @@ export class CodexPluginController { nodeId: execContext?.node?.trim() || undefined, }; } + const resolvedSessionKey = this.resolveConversationSessionKey(conversation, sessionKey); + const agentEndpointId = this.resolveEndpointIdFromAgentDefault(resolvedSessionKey); + if (agentEndpointId) { + return { + endpointId: agentEndpointId, + source: "agent-default", + agentId: this.resolveAgentIdFromSessionKey(resolvedSessionKey), + }; + } return { endpointId: this.settings.defaultEndpoint, source: "default", @@ -2296,6 +2345,9 @@ export class CodexPluginController { if (selection.source === "auto-node") { return `${selection.endpointId} (auto from node${selection.nodeId ? `: ${selection.nodeId}` : ""})`; } + if (selection.source === "agent-default") { + return `${selection.endpointId} (agent default${selection.agentId ? `: ${selection.agentId}` : ""})`; + } return `${selection.endpointId} (default)`; } diff --git a/src/types.ts b/src/types.ts index d7476bf..342bc91 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,7 @@ export type EndpointSettings = { export type PluginSettings = { enabled: boolean; defaultEndpoint: string; + agentEndpoints: Record; endpoints: EndpointSettings[]; defaultWorkspaceDir?: string; defaultModel?: string;