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 ffc3ae8..c97bb17 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 | @@ -135,6 +167,7 @@ Pre-release packages are published on matching npm dist-tags instead of `latest` | `/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. | @@ -208,10 +241,44 @@ 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 - `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/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/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/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..b0514b4 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": { @@ -41,6 +47,59 @@ "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" + }, + "execNodes": { + "type": "array", + "items": { + "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 @@ -53,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 + } + } } } }, @@ -67,6 +148,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 @@ -86,6 +171,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 @@ -100,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/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..51a38df --- /dev/null +++ b/src/agent-tools.ts @@ -0,0 +1,291 @@ +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 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 { + 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; + runtimeConfig?: { + tools?: { + exec?: { + host?: string; + node?: 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), + execContext: readToolExecContext(ctx), + 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), + execContext: readToolExecContext(ctx), + 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), + execContext: readToolExecContext(ctx), + 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 c0bf4ff..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,14 @@ export function isMissingThreadError(error: unknown): boolean { ); } -function buildFullAccessPluginSettings(settings: PluginSettings): PluginSettings | null { +function buildFullAccessPluginSettings( + settings: ClientEndpointSettings, +): ClientEndpointSettings | null { + if (settings.transport === "websocket") { + return { + ...settings, + }; + } if (settings.transport !== "stdio") { return null; } @@ -2445,7 +2454,7 @@ export class CodexAppServerClient { private readonly requestListeners = new Set(); constructor( - private readonly settings: PluginSettings, + private readonly settings: ClientEndpointSettings, private readonly logger: PluginLogger, ) {} @@ -2538,7 +2547,7 @@ export class CodexAppServerClient { params: { sessionKey?: string }, callback: (args: { client: JsonRpcClient; - settings: PluginSettings; + settings: EndpointSettings; initializeResult: unknown; }) => Promise, ): Promise { @@ -3768,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..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."], @@ -12,6 +13,8 @@ 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."], ["cas_diff", "Forward /diff to Codex."], diff --git a/src/config.ts b/src/config.ts index 5d1ab5f..9f0b3e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,8 @@ -import type { PluginSettings } from "./types.js"; +import type { + EndpointSettings, + InboundAudioTranscriptionSettings, + PluginSettings, +} from "./types.js"; import { DEFAULT_REQUEST_TIMEOUT_MS, } from "./types.js"; @@ -43,6 +47,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, @@ -56,32 +68,94 @@ 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 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), + execNodes: readStringArray(entry, "execNodes"), + 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", + execNodes: readStringArray(record, "execNodes"), + 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"), + inboundAudioTranscription: resolveInboundAudioTranscription(record), }; } diff --git a/src/controller.test.ts b/src/controller.test.ts index af9973e..083277e 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,11 +42,15 @@ 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-")); } -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 +139,7 @@ function createApiMock() { pluginConfig: { enabled: true, defaultWorkspaceDir: "/repo/openclaw", + ...pluginConfigOverrides, }, logger: { debug: vi.fn(), @@ -205,7 +214,7 @@ function createApiMock() { }; } -async function createControllerHarness() { +async function createControllerHarness(pluginConfigOverrides: Record = {}) { const { api, sendComponentMessage, @@ -217,7 +226,7 @@ async function createControllerHarness() { editChannel, discordOutbound, stateDir, - } = createApiMock(); + } = createApiMock(pluginConfigOverrides); const controller = new CodexPluginController(api); await controller.start(); const threadState: any = { @@ -312,6 +321,99 @@ async function createControllerHarness() { }; } +async function createControllerHarnessWithPluginConfig(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; @@ -553,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", @@ -613,9 +717,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({ @@ -632,9 +735,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", @@ -665,9 +767,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({ @@ -680,6 +781,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(); @@ -950,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, @@ -1053,7 +1164,7 @@ describe("Discord controller flows", () => { }), ); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(clientMock.startThread).toHaveBeenCalledWith({ profile: "default", sessionKey: undefined, @@ -1106,7 +1217,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", @@ -1128,7 +1239,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", @@ -1189,9 +1300,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({ @@ -2663,7 +2773,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; @@ -2863,7 +2973,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", @@ -2895,7 +3006,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; @@ -2949,7 +3060,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.", @@ -2992,7 +3104,7 @@ describe("Discord controller flows", () => { await flushAsyncWork(); - expect(reply).toEqual({}); + expect(reply.text).toContain("Resolved endpoint: default (default)"); expect(renameTopic).toHaveBeenCalledWith( "123", 456, @@ -4036,8 +4148,168 @@ describe("Discord controller flows", () => { expect(startTurn).toHaveBeenCalled(); }); - it("does not claim inbound Discord messages when only core binding state exists", async () => { + 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({ + 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?", @@ -4048,7 +4320,91 @@ 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("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 () => { @@ -4225,6 +4581,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"); @@ -4621,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", @@ -4642,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 () => { @@ -6815,4 +7293,271 @@ 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"); + }); + + 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", + 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 4ba6fb1..ea0bad1 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"; @@ -55,9 +56,12 @@ import type { CollaborationMode, CodexTurnInputItem, ConversationPreferences, + EndpointSettings, InteractiveMessageRef, + PendingInputState, PermissionsMode, ThreadState, + TurnResult, TurnTerminalError, } from "./types.js"; import { @@ -85,11 +89,11 @@ import { paginateItems, } from "./thread-picker.js"; import { + DEFAULT_REQUEST_TIMEOUT_MS, INTERACTIVE_NAMESPACE, PLUGIN_ID, type CallbackAction, type ConversationTarget, - type PendingInputState, type StoredBinding, type StoredPendingBind, type StoredPendingRequest, @@ -169,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", @@ -190,6 +195,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 = { @@ -490,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", @@ -501,6 +515,7 @@ function resolveDiscordCommandConversation( parentConversationId: normalizeDiscordChannelConversationId( readCommandContextId(ctx, "threadParentId"), ), + threadId: denormalizeDiscordConversationId(threadConversationId) ?? rawThreadId, }; } const candidates = [ctx.from, ctx.to] @@ -573,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; @@ -589,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, }; } @@ -662,6 +709,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( @@ -790,18 +850,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; } @@ -1210,6 +1333,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, @@ -1307,6 +1445,17 @@ type WorkspaceChoice = { latestUpdatedAt?: number; }; +type AgentExecContext = { + host?: string; + 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, @@ -1360,7 +1509,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 +1519,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 +1540,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; } @@ -1403,11 +1553,681 @@ export class CodexPluginController { for (const active of this.activeRuns.values()) { await active.handle.interrupt().catch(() => undefined); } - this.activeRuns.clear(); - await this.client.close().catch(() => undefined); - this.started = false; + this.activeRuns.clear(); + 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; + execNodes: 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}`, + execNodes: [...(endpoint.execNodes ?? [])], + 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; + execContext?: AgentExecContext; + 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 = await this.resolveAgentEndpointIdWithNodeFallback( + params.endpointId, + params.execContext, + ); + 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; + execContext?: AgentExecContext; + threadId: string; + permissionsMode?: PermissionsMode; + }): Promise<{ + endpointId: string; + permissionsMode: PermissionsMode; + threadId: string; + state: ThreadState; + context: Awaited>; + }> { + await this.start(); + 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); + 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; + execContext?: AgentExecContext; + 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 = await this.resolveAgentEndpointIdWithNodeFallback( + params.endpointId, + params.execContext, + ); + 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, execContext?: AgentExecContext): string { + const requested = endpointId?.trim(); + if (requested) { + if (!this.settings.endpoints.some((entry) => entry.id === requested)) { + throw new Error(`Unknown Codex endpoint: ${requested}`); + } + return requested; + } + const inferred = this.resolveEndpointIdFromExecContext(execContext); + if (inferred) { + return inferred; + } + 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") { + 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( + 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 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, + ): string { + return this.getSelectedEndpointResolution(conversation).endpointId; + } + + private getSelectedEndpointResolution( + conversation: ConversationTarget | null | undefined, + ): EndpointResolution { + 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, + }; + } + return { + endpointId: this.settings.defaultEndpoint, + source: "default", + }; + } + + 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 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: { + conversation?: ConversationTarget | null; + selection: EndpointResolution; + binding?: StoredBinding | null; + }): string { + const manualEndpointId = this.getManualEndpointId(params.conversation ?? params.binding?.conversation ?? null); + const lines = [ + `Active endpoint: ${this.formatEndpointResolutionLabel(params.selection)}`, + `Manual override: ${manualEndpointId ?? "none"}`, + params.binding + ? `Bound endpoint: ${this.getEndpointIdForBinding(params.binding)}` + : "Bound endpoint: none", + "", + "Configured endpoints:", + ...this.settings.endpoints.map((endpoint) => { + const markers = [ + 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); + return `- ${endpoint.id} (${endpoint.transport})${markers.length ? ` [${markers.join(", ")}]` : ""}`; + }), + ]; + if ( + params.binding && + 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 active endpoint.", + ); + } + return lines.join("\n"); + } + + private buildEndpointSelectionNotice( + selection: EndpointResolution, + binding?: StoredBinding | null, + conversation?: ConversationTarget | null, + ): string { + return [ + 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({ + conversation, + selection, + 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(); + } + + 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 { @@ -1418,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; } @@ -1430,6 +2262,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}`, ); @@ -1441,6 +2279,7 @@ export class CodexPluginController { } await this.bindConversation(conversation, { threadId: pending.threadId, + endpointId: pending.endpointId, workspaceDir: pending.workspaceDir, threadTitle: pending.threadTitle, permissionsMode: normalizePermissionsMode(pending.permissionsMode), @@ -1472,6 +2311,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; @@ -1492,7 +2365,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); @@ -1541,7 +2417,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"}`, ); @@ -1895,16 +2775,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." }; @@ -1916,6 +2816,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, @@ -1943,6 +2852,9 @@ 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": return await this.handlePermissionsCommand( conversation, @@ -1967,6 +2879,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 +2888,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 +2903,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 +2921,7 @@ export class CodexPluginController { const result = await this.startNewThreadAndBindConversation( conversation, binding, + endpointId, workspaceDir, parsed.syncTopic, { @@ -2029,6 +2943,7 @@ export class CodexPluginController { private async handleListCommand( conversation: ConversationTarget | null, binding: StoredBinding | null, + endpointId: string | undefined, filter: string, channel: string, ): Promise { @@ -2037,8 +2952,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 +2983,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 +3008,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 +3033,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 +3066,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 +3079,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 +3098,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 +3120,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 +3199,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 +3249,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 +3282,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 +3306,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 +3360,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 +3371,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 +3388,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 +3411,7 @@ export class CodexPluginController { ) ) { try { - state = await this.client.setThreadPermissions({ + state = await client.setThreadPermissions({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -2488,11 +3436,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 +3483,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 +3630,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 +3678,75 @@ export class CodexPluginController { }; } + private async buildEndpointPicker( + conversation: ConversationTarget, + binding: StoredBinding | null, + opts?: { + returnToStatus?: boolean; + statusMessage?: InteractiveMessageRef; + }, + ): Promise { + 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({ + kind: "set-endpoint", + conversation, + endpointId, + returnToStatus: opts?.returnToStatus, + statusMessage: opts?.statusMessage, + }); + const flags = [ + endpointId === selection.endpointId ? "active" : "", + endpointId === manualEndpointId ? "manual" : "", + 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({ + conversation, + selection, + binding, + }), + buttons, + }; + } + private async buildReasoningPicker( conversation: ConversationTarget, binding: StoredBinding, @@ -2822,7 +3848,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 +4103,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 +4159,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 +4194,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 +4202,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 +4239,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 +4271,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 +4299,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 +4310,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 +4336,48 @@ 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 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 { + text: [ + `Unknown endpoint: ${requested}`, + "", + this.formatEndpointListText({ + conversation, + selection: currentSelection, + binding, + }), + ].join("\n"), + }; + } + await this.setSelectedEndpointId(conversation, endpoint.id || requested); + const nextSelection = this.getSelectedEndpointResolution(conversation); + return { text: this.buildEndpointSelectionNotice(nextSelection, binding, conversation) }; + } + private async handlePermissionsCommand( conversation: ConversationTarget | null, binding: StoredBinding | null, @@ -3354,7 +4424,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 +4532,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 +4607,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 +4651,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 +4660,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 +4860,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 +4904,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 +4913,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 +5037,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 +5049,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 +5176,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 +5203,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 +5468,7 @@ export class CodexPluginController { parsed: ReturnType; projectName?: string; filterProjectsOnly?: boolean; + endpointId?: string; }, ) { const workspaceDir = this.resolveThreadWorkspaceDir( @@ -4400,7 +5477,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 +5548,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 +5560,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 +5591,7 @@ export class CodexPluginController { projectName?: string; page: number; totalPages: number; + endpointId?: string; }): Promise { if (params.totalPages > 1) { const navRow: PluginInteractiveButtons[number] = []; @@ -4523,6 +5603,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 +5626,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 +5654,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 +5671,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 +5712,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 +5743,7 @@ export class CodexPluginController { parsed, threads: pageResult.items, showProjectName: !projectName && (fallbackToGlobal || distinctProjects.size > 1), + endpointId, })) ?? []; return { text: formatThreadPickerIntro({ @@ -4674,6 +5761,7 @@ export class CodexPluginController { buttons: threadButtons, parsed, projectName, + endpointId, page: pageResult.page, totalPages: pageResult.totalPages, }), @@ -4686,10 +5774,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 +5794,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 +5810,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 +5827,7 @@ export class CodexPluginController { mode: "threads", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName: option.name, requestedModel: parsed.requestedModel, @@ -4761,6 +5854,7 @@ export class CodexPluginController { action, includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, query: parsed.query || undefined, requestedModel: parsed.requestedModel, @@ -4848,11 +5942,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 +5957,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 +5982,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName, requestedModel: parsed.requestedModel, @@ -4907,6 +6005,7 @@ export class CodexPluginController { action: "start-new-thread", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, projectName, requestedModel: parsed.requestedModel, @@ -4933,6 +6032,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 +6047,7 @@ export class CodexPluginController { mode: "threads", includeAll: true, syncTopic: parsed.syncTopic, + endpointId, workspaceDir: parsed.cwd, requestedModel: parsed.requestedModel, requestedFast: parsed.requestedFast, @@ -4993,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, @@ -5217,6 +6320,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 +6345,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 +6370,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 +6591,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 +6721,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 +6967,55 @@ 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({ + conversation, + selection: this.getSelectedEndpointResolution(conversation), + 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 +7060,100 @@ 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( + 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) { + 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 === "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); @@ -5916,7 +7165,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 +7182,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 +7307,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 +7316,7 @@ export class CodexPluginController { parsed!, callback.view.page, callback.view.projectName, + callback.view.endpointId, ) : callback.view.mode === "skills" ? await this.buildSkillsPicker( @@ -6083,6 +7334,7 @@ export class CodexPluginController { parsed!, callback.view.page, callback.view.projectName, + callback.view.endpointId, ); await responders.editPicker(picker); } @@ -6090,6 +7342,7 @@ export class CodexPluginController { private async startNewThreadAndBindConversation( conversation: ConversationTarget, binding: StoredBinding | null, + endpointId: string | undefined, workspaceDir: string, syncTopic: boolean, overrides: CommandPreferenceOverrides, @@ -6103,7 +7356,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 +7372,7 @@ export class CodexPluginController { conversation, { threadId: created.threadId, + endpointId: resolvedEndpointId, workspaceDir: created.cwd?.trim() || workspaceDir, threadTitle: created.threadName, permissionsMode: profile, @@ -6148,6 +7403,7 @@ export class CodexPluginController { } private async resolveSingleThread( + endpointId: string | undefined, sessionKey: string | undefined, workspaceDir: string | undefined, filter: string, @@ -6157,7 +7413,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 +7441,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 +7454,7 @@ export class CodexPluginController { sandbox: preferredPermissions.sandbox, }) .catch(() => - this.client.readThreadState({ + this.getClientForBinding(binding).readThreadState({ profile, sessionKey: binding.sessionKey, threadId: binding.threadId, @@ -6242,6 +7498,7 @@ export class CodexPluginController { conversation: ConversationTarget, params: { threadId: string; + endpointId?: string; workspaceDir: string; threadTitle?: string; permissionsMode?: PermissionsMode; @@ -6260,6 +7517,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 +7546,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 +7559,7 @@ export class CodexPluginController { conversation: ConversationTarget, params: { threadId: string; + endpointId?: string; workspaceDir: string; permissionsMode?: PermissionsMode; threadTitle?: string; @@ -6344,6 +7604,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 +7676,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 +7697,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 +7834,8 @@ export class CodexPluginController { binding: StoredBinding | null, bindingActive: boolean, ): Promise { + const selection = this.getSelectedEndpointResolution(conversation); + const selectedEndpointId = selection.endpointId; const activeRun = bindingActive && conversation ? this.activeRuns.get(buildConversationKey(conversation)) @@ -6585,18 +7848,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 +7880,18 @@ 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 + ? `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() || ""}`, ); return formatCodexStatusText({ pluginVersion: PLUGIN_VERSION, + endpointId: selectedEndpointId, + endpointLabel: this.formatEndpointResolutionLabel(selection), threadState: displayThreadState, bindingThreadTitle: binding?.threadTitle, account, @@ -6633,7 +7903,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, }); @@ -6872,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: { @@ -6972,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, @@ -7096,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, @@ -7109,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, }); @@ -7189,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); } @@ -7210,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); @@ -7601,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/format.ts b/src/format.ts index 41c34f4..0bdde8e 100644 --- a/src/format.ts +++ b/src/format.ts @@ -525,6 +525,8 @@ export function formatCodexContextUsageSnapshot( export function formatCodexStatusText(params: { pluginVersion?: string; + endpointId?: string; + endpointLabel?: string; threadState?: ThreadState; bindingThreadTitle?: string; account?: AccountSummary | null; @@ -550,6 +552,10 @@ export function formatCodexStatusText(params: { if (params.pluginVersion?.trim()) { lines.push(`Plugin version: ${params.pluginVersion.trim()}`); } + const endpointLabel = params.endpointLabel?.trim() || params.endpointId?.trim(); + if (endpointLabel) { + lines.push(`Endpoint: ${endpointLabel}`); + } if (params.threadState) { lines.push(`Model: ${formatCodexModelText(params.threadState)}`); } @@ -616,6 +622,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..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]", @@ -147,6 +153,24 @@ 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|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: "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, usage: "/cas_permissions", 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 8cdc282..ca97aa9 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,23 @@ type PutCallbackInput = token?: string; ttlMs?: number; } + | { + kind: "set-endpoint"; + conversation: ConversationTarget; + endpointId: string; + returnToStatus?: boolean; + statusMessage?: Extract["statusMessage"]; + token?: string; + ttlMs?: number; + } + | { + kind: "clear-endpoint"; + conversation: ConversationTarget; + returnToStatus?: boolean; + statusMessage?: Extract["statusMessage"]; + token?: string; + ttlMs?: number; + } | { kind: "reply-text"; conversation: ConversationTarget; @@ -190,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 [ @@ -204,6 +278,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 +338,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 +364,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 +374,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; } @@ -349,11 +436,43 @@ 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; } + 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 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( @@ -367,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(); } @@ -452,6 +570,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 +584,7 @@ export class PluginStateStore { ? { kind: "resume-thread", conversation: callback.conversation, + endpointId: callback.endpointId, threadId: callback.threadId, threadTitle: callback.threadTitle, workspaceDir: callback.workspaceDir, @@ -651,6 +771,35 @@ 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 === "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 f6e161f..33978a3 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,17 +11,32 @@ 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; + execNodes?: 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; + inboundAudioTranscription?: InboundAudioTranscriptionSettings; +}; + +export type InboundAudioTranscriptionSettings = { + enabled: boolean; + command?: string; + args: string[]; + timeoutMs: number; }; export type CodexPlanStep = { @@ -274,6 +289,7 @@ export type StoredBinding = { conversation: ConversationRef; sessionKey: string; threadId: string; + endpointId?: string; workspaceDir: string; permissionsMode?: PermissionsMode; pendingPermissionsMode?: PermissionsMode; @@ -299,6 +315,7 @@ export type InteractiveMessageRef = export type StoredPendingBind = { conversation: ConversationRef; threadId: string; + endpointId?: string; workspaceDir: string; permissionsMode?: PermissionsMode; threadTitle?: string; @@ -312,17 +329,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 +360,7 @@ export type CallbackAction = token: string; kind: "resume-thread"; conversation: ConversationRef; + endpointId?: string; threadId: string; threadTitle?: string; workspaceDir: string; @@ -375,6 +401,7 @@ export type CallbackAction = includeAll: boolean; page: number; syncTopic?: boolean; + endpointId?: string; query?: string; workspaceDir?: string; projectName?: string; @@ -388,6 +415,7 @@ export type CallbackAction = includeAll: boolean; page: number; syncTopic?: boolean; + endpointId?: string; query?: string; workspaceDir?: string; projectName?: string; @@ -401,6 +429,7 @@ export type CallbackAction = includeAll: boolean; page: number; syncTopic?: boolean; + endpointId?: string; workspaceDir?: string; projectName: string; requestedModel?: string; @@ -525,6 +554,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 +571,25 @@ 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: "clear-endpoint"; + conversation: ConversationRef; + returnToStatus?: boolean; + statusMessage?: InteractiveMessageRef; + createdAt: number; + expiresAt: number; + } | { token: string; kind: "reply-text"; @@ -563,13 +618,14 @@ export type CallbackAction = export type StoreSnapshot = { version: number; bindings: StoredBinding[]; + conversationEndpoints: StoredConversationEndpoint[]; pendingBinds: StoredPendingBind[]; pendingRequests: StoredPendingRequest[]; callbacks: CallbackAction[]; }; export type ConversationTarget = ConversationRef & { - threadId?: number; + threadId?: number | string; }; export type CommandButtons = PluginInteractiveButtons;