From a26a9cfabd05ccf774045b3685e50d3605516cdb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 13 Feb 2026 23:41:49 +0100 Subject: [PATCH] feat: add configurable transport and codex websocket session caching --- .pi/extensions/tps.ts | 47 ++ packages/agent/CHANGELOG.md | 4 + packages/agent/src/agent.ts | 23 + packages/ai/CHANGELOG.md | 10 + packages/ai/README.md | 2 +- .../src/providers/openai-codex-responses.ts | 406 ++++++++++++++++++ packages/ai/src/types.ts | 7 + packages/ai/test/stream.test.ts | 29 ++ packages/coding-agent/CHANGELOG.md | 9 + packages/coding-agent/README.md | 4 +- packages/coding-agent/docs/settings.md | 4 +- packages/coding-agent/src/core/sdk.ts | 1 + .../coding-agent/src/core/settings-manager.ts | 20 + .../components/settings-selector.ts | 13 + .../src/modes/interactive/interactive-mode.ts | 5 + 15 files changed, 580 insertions(+), 4 deletions(-) create mode 100644 .pi/extensions/tps.ts diff --git a/.pi/extensions/tps.ts b/.pi/extensions/tps.ts new file mode 100644 index 00000000..16bea8f6 --- /dev/null +++ b/.pi/extensions/tps.ts @@ -0,0 +1,47 @@ +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +function isAssistantMessage(message: unknown): message is AssistantMessage { + if (!message || typeof message !== "object") return false; + const role = (message as { role?: unknown }).role; + return role === "assistant"; +} + +export default function (pi: ExtensionAPI) { + let agentStartMs: number | null = null; + + pi.on("agent_start", () => { + agentStartMs = Date.now(); + }); + + pi.on("agent_end", (event, ctx) => { + if (!ctx.hasUI) return; + if (agentStartMs === null) return; + + const elapsedMs = Date.now() - agentStartMs; + agentStartMs = null; + if (elapsedMs <= 0) return; + + let input = 0; + let output = 0; + let cacheRead = 0; + let cacheWrite = 0; + let totalTokens = 0; + + for (const message of event.messages) { + if (!isAssistantMessage(message)) continue; + input += message.usage.input || 0; + output += message.usage.output || 0; + cacheRead += message.usage.cacheRead || 0; + cacheWrite += message.usage.cacheWrite || 0; + totalTokens += message.usage.totalTokens || 0; + } + + if (output <= 0) return; + + const elapsedSeconds = elapsedMs / 1000; + const tokensPerSecond = output / elapsedSeconds; + const message = `TPS ${tokensPerSecond.toFixed(1)} tok/s. out ${output.toLocaleString()}, in ${input.toLocaleString()}, cache r/w ${cacheRead.toLocaleString()}/${cacheWrite.toLocaleString()}, total ${totalTokens.toLocaleString()}, ${elapsedSeconds.toFixed(1)}s`; + ctx.ui.notify(message, "info"); + }); +} diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 3c8d5bac..d46a7f0d 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `transport` to `AgentOptions` and `AgentLoopConfig` forwarding, allowing stream transport preference (`"sse"`, `"websocket"`, `"auto"`) to flow into provider calls. + ## [0.52.11] - 2026-02-13 ## [0.52.10] - 2026-02-12 diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 1f310281..b61f8d14 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -11,6 +11,7 @@ import { streamSimple, type TextContent, type ThinkingBudgets, + type Transport, } from "@mariozechner/pi-ai"; import { agentLoop, agentLoopContinue } from "./agent-loop.js"; import type { @@ -78,6 +79,11 @@ export interface AgentOptions { */ thinkingBudgets?: ThinkingBudgets; + /** + * Preferred transport for providers that support multiple transports. + */ + transport?: Transport; + /** * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. * If the server's requested delay exceeds this value, the request fails immediately, @@ -114,6 +120,7 @@ export class Agent { private runningPrompt?: Promise; private resolveRunningPrompt?: () => void; private _thinkingBudgets?: ThinkingBudgets; + private _transport: Transport; private _maxRetryDelayMs?: number; constructor(opts: AgentOptions = {}) { @@ -126,6 +133,7 @@ export class Agent { this._sessionId = opts.sessionId; this.getApiKey = opts.getApiKey; this._thinkingBudgets = opts.thinkingBudgets; + this._transport = opts.transport ?? "sse"; this._maxRetryDelayMs = opts.maxRetryDelayMs; } @@ -158,6 +166,20 @@ export class Agent { this._thinkingBudgets = value; } + /** + * Get the current preferred transport. + */ + get transport(): Transport { + return this._transport; + } + + /** + * Set the preferred transport. + */ + setTransport(value: Transport) { + this._transport = value; + } + /** * Get the current max retry delay in milliseconds. */ @@ -407,6 +429,7 @@ export class Agent { model, reasoning, sessionId: this._sessionId, + transport: this._transport, thinkingBudgets: this._thinkingBudgets, maxRetryDelayMs: this._maxRetryDelayMs, convertToLlm: this.convertToLlm, diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8183e9fe..18c371d6 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added + +- Added `transport` to `StreamOptions` with values `"sse"`, `"websocket"`, and `"auto"` (currently supported by `openai-codex-responses`). +- Added WebSocket transport support for OpenAI Codex Responses (`openai-codex-responses`). + +### Changed + +- OpenAI Codex Responses now defaults to SSE transport unless `transport` is explicitly set. +- OpenAI Codex Responses WebSocket connections are cached per `sessionId` and expire after 5 minutes of inactivity. + ## [0.52.11] - 2026-02-13 ### Added diff --git a/packages/ai/README.md b/packages/ai/README.md index b4a4a641..931a8f55 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1086,7 +1086,7 @@ const response = await complete(model, { ### Provider Notes -**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options. +**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId`, connections are reused per session and expire after 5 minutes of inactivity. **Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported. diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 0b163cb9..f92c374d 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -136,6 +136,40 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" options?.onPayload?.(body); const headers = buildHeaders(model.headers, options?.headers, accountId, apiKey, options?.sessionId); const bodyJson = JSON.stringify(body); + const transport = options?.transport || "sse"; + + if (transport !== "sse") { + let websocketStarted = false; + try { + await processWebSocketStream( + resolveCodexWebSocketUrl(model.baseUrl), + body, + headers, + output, + stream, + model, + () => { + websocketStarted = true; + }, + options, + ); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + stream.push({ + type: "done", + reason: output.stopReason as "stop" | "length" | "toolUse", + message: output, + }); + stream.end(); + return; + } catch (error) { + if (transport === "websocket" || websocketStarted) { + throw error; + } + } + } // Fetch with retry logic for rate limits and transient errors let response: Response | undefined; @@ -296,6 +330,13 @@ function resolveCodexUrl(baseUrl?: string): string { return `${normalized}/codex/responses`; } +function resolveCodexWebSocketUrl(baseUrl?: string): string { + const url = new URL(resolveCodexUrl(baseUrl)); + if (url.protocol === "https:") url.protocol = "wss:"; + if (url.protocol === "http:") url.protocol = "ws:"; + return url.toString(); +} + // ============================================================================ // Response Processing // ============================================================================ @@ -381,6 +422,371 @@ async function* parseSSE(response: Response): AsyncGenerator void; + +interface WebSocketLike { + close(code?: number, reason?: string): void; + send(data: string): void; + addEventListener(type: WebSocketEventType, listener: WebSocketListener): void; + removeEventListener(type: WebSocketEventType, listener: WebSocketListener): void; +} + +interface CachedWebSocketConnection { + socket: WebSocketLike; + busy: boolean; + idleTimer?: ReturnType; +} + +const websocketSessionCache = new Map(); + +type WebSocketConstructor = new ( + url: string, + protocols?: string | string[] | { headers?: Record }, +) => WebSocketLike; + +function getWebSocketConstructor(): WebSocketConstructor | null { + const ctor = (globalThis as { WebSocket?: unknown }).WebSocket; + if (typeof ctor !== "function") return null; + return ctor as unknown as WebSocketConstructor; +} + +function headersToRecord(headers: Headers): Record { + const out: Record = {}; + for (const [key, value] of headers.entries()) { + out[key] = value; + } + return out; +} + +function getWebSocketReadyState(socket: WebSocketLike): number | undefined { + const readyState = (socket as { readyState?: unknown }).readyState; + return typeof readyState === "number" ? readyState : undefined; +} + +function isWebSocketReusable(socket: WebSocketLike): boolean { + const readyState = getWebSocketReadyState(socket); + // If readyState is unavailable, assume the runtime keeps it open/reusable. + return readyState === undefined || readyState === 1; +} + +function closeWebSocketSilently(socket: WebSocketLike, code = 1000, reason = "done"): void { + try { + socket.close(code, reason); + } catch {} +} + +function scheduleSessionWebSocketExpiry(sessionId: string, entry: CachedWebSocketConnection): void { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer); + } + entry.idleTimer = setTimeout(() => { + if (entry.busy) return; + closeWebSocketSilently(entry.socket, 1000, "idle_timeout"); + websocketSessionCache.delete(sessionId); + }, SESSION_WEBSOCKET_CACHE_TTL_MS); +} + +async function connectWebSocket(url: string, headers: Headers, signal?: AbortSignal): Promise { + const WebSocketCtor = getWebSocketConstructor(); + if (!WebSocketCtor) { + throw new Error("WebSocket transport is not available in this runtime"); + } + + const wsHeaders = headersToRecord(headers); + wsHeaders["OpenAI-Beta"] = OPENAI_BETA_RESPONSES_WEBSOCKETS; + + return new Promise((resolve, reject) => { + let settled = false; + let socket: WebSocketLike; + + try { + socket = new WebSocketCtor(url, { headers: wsHeaders }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } + + const onOpen: WebSocketListener = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(socket); + }; + const onError: WebSocketListener = (event) => { + if (settled) return; + settled = true; + cleanup(); + reject(extractWebSocketError(event)); + }; + const onClose: WebSocketListener = (event) => { + if (settled) return; + settled = true; + cleanup(); + reject(extractWebSocketCloseError(event)); + }; + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + socket.close(1000, "aborted"); + reject(new Error("Request was aborted")); + }; + + const cleanup = () => { + socket.removeEventListener("open", onOpen); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + signal?.removeEventListener("abort", onAbort); + }; + + socket.addEventListener("open", onOpen); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + signal?.addEventListener("abort", onAbort); + }); +} + +async function acquireWebSocket( + url: string, + headers: Headers, + sessionId: string | undefined, + signal?: AbortSignal, +): Promise<{ socket: WebSocketLike; release: (options?: { keep?: boolean }) => void }> { + if (!sessionId) { + const socket = await connectWebSocket(url, headers, signal); + return { + socket, + release: ({ keep } = {}) => { + if (keep === false) { + closeWebSocketSilently(socket); + return; + } + closeWebSocketSilently(socket); + }, + }; + } + + const cached = websocketSessionCache.get(sessionId); + if (cached) { + if (cached.idleTimer) { + clearTimeout(cached.idleTimer); + cached.idleTimer = undefined; + } + if (!cached.busy && isWebSocketReusable(cached.socket)) { + cached.busy = true; + return { + socket: cached.socket, + release: ({ keep } = {}) => { + if (!keep || !isWebSocketReusable(cached.socket)) { + closeWebSocketSilently(cached.socket); + websocketSessionCache.delete(sessionId); + return; + } + cached.busy = false; + scheduleSessionWebSocketExpiry(sessionId, cached); + }, + }; + } + if (cached.busy) { + const socket = await connectWebSocket(url, headers, signal); + return { + socket, + release: () => { + closeWebSocketSilently(socket); + }, + }; + } + if (!isWebSocketReusable(cached.socket)) { + closeWebSocketSilently(cached.socket); + websocketSessionCache.delete(sessionId); + } + } + + const socket = await connectWebSocket(url, headers, signal); + const entry: CachedWebSocketConnection = { socket, busy: true }; + websocketSessionCache.set(sessionId, entry); + return { + socket, + release: ({ keep } = {}) => { + if (!keep || !isWebSocketReusable(entry.socket)) { + closeWebSocketSilently(entry.socket); + if (entry.idleTimer) clearTimeout(entry.idleTimer); + if (websocketSessionCache.get(sessionId) === entry) { + websocketSessionCache.delete(sessionId); + } + return; + } + entry.busy = false; + scheduleSessionWebSocketExpiry(sessionId, entry); + }, + }; +} + +function extractWebSocketError(event: unknown): Error { + if (event && typeof event === "object" && "message" in event) { + const message = (event as { message?: unknown }).message; + if (typeof message === "string" && message.length > 0) { + return new Error(message); + } + } + return new Error("WebSocket error"); +} + +function extractWebSocketCloseError(event: unknown): Error { + if (event && typeof event === "object") { + const code = "code" in event ? (event as { code?: unknown }).code : undefined; + const reason = "reason" in event ? (event as { reason?: unknown }).reason : undefined; + const codeText = typeof code === "number" ? ` ${code}` : ""; + const reasonText = typeof reason === "string" && reason.length > 0 ? ` ${reason}` : ""; + return new Error(`WebSocket closed${codeText}${reasonText}`.trim()); + } + return new Error("WebSocket closed"); +} + +async function decodeWebSocketData(data: unknown): Promise { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) { + return new TextDecoder().decode(new Uint8Array(data)); + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + return new TextDecoder().decode(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + } + if (data && typeof data === "object" && "arrayBuffer" in data) { + const blobLike = data as { arrayBuffer: () => Promise }; + const arrayBuffer = await blobLike.arrayBuffer(); + return new TextDecoder().decode(new Uint8Array(arrayBuffer)); + } + return null; +} + +async function* parseWebSocket(socket: WebSocketLike, signal?: AbortSignal): AsyncGenerator> { + const queue: Record[] = []; + let pending: (() => void) | null = null; + let done = false; + let failed: Error | null = null; + let sawCompletion = false; + + const wake = () => { + if (!pending) return; + const resolve = pending; + pending = null; + resolve(); + }; + + const onMessage: WebSocketListener = (event) => { + void (async () => { + if (!event || typeof event !== "object" || !("data" in event)) return; + const text = await decodeWebSocketData((event as { data?: unknown }).data); + if (!text) return; + try { + const parsed = JSON.parse(text) as Record; + const type = typeof parsed.type === "string" ? parsed.type : ""; + if (type === "response.completed" || type === "response.done") { + sawCompletion = true; + done = true; + } + queue.push(parsed); + wake(); + } catch {} + })(); + }; + + const onError: WebSocketListener = (event) => { + failed = extractWebSocketError(event); + done = true; + wake(); + }; + + const onClose: WebSocketListener = (event) => { + if (sawCompletion) { + done = true; + wake(); + return; + } + if (!failed) { + failed = extractWebSocketCloseError(event); + } + done = true; + wake(); + }; + + const onAbort = () => { + failed = new Error("Request was aborted"); + done = true; + wake(); + }; + + socket.addEventListener("message", onMessage); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + signal?.addEventListener("abort", onAbort); + + try { + while (true) { + if (signal?.aborted) { + throw new Error("Request was aborted"); + } + if (queue.length > 0) { + yield queue.shift()!; + continue; + } + if (done) break; + await new Promise((resolve) => { + pending = resolve; + }); + } + + if (failed) { + throw failed; + } + if (!sawCompletion) { + throw new Error("WebSocket stream closed before response.completed"); + } + } finally { + socket.removeEventListener("message", onMessage); + socket.removeEventListener("error", onError); + socket.removeEventListener("close", onClose); + signal?.removeEventListener("abort", onAbort); + } +} + +async function processWebSocketStream( + url: string, + body: RequestBody, + headers: Headers, + output: AssistantMessage, + stream: AssistantMessageEventStream, + model: Model<"openai-codex-responses">, + onStart: () => void, + options?: OpenAICodexResponsesOptions, +): Promise { + const { socket, release } = await acquireWebSocket(url, headers, options?.sessionId, options?.signal); + let keepConnection = true; + try { + socket.send(JSON.stringify({ type: "response.create", ...body })); + onStart(); + stream.push({ type: "start", partial: output }); + await processResponsesStream(mapCodexEvents(parseWebSocket(socket, options?.signal)), output, stream, model); + if (options?.signal?.aborted) { + keepConnection = false; + } + } catch (error) { + keepConnection = false; + throw error; + } finally { + release({ keep: keepConnection }); + } +} + // ============================================================================ // Error Handling // ============================================================================ diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 89bcb303..9a51ee22 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -53,11 +53,18 @@ export interface ThinkingBudgets { // Base options all providers share export type CacheRetention = "none" | "short" | "long"; +export type Transport = "sse" | "websocket" | "auto"; + export interface StreamOptions { temperature?: number; maxTokens?: number; signal?: AbortSignal; apiKey?: string; + /** + * Preferred transport for providers that support multiple transports. + * Providers that do not support this option ignore it. + */ + transport?: Transport; /** * Prompt cache retention preference. Providers map this to their supported values. * Default: "short". diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index b29c6972..3120b559 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1190,6 +1190,35 @@ describe("Generate E2E Tests", () => { }); }); + describe("OpenAI Codex Provider (gpt-5.3-codex via WebSocket)", () => { + const llm = getModel("openai-codex", "gpt-5.3-codex"); + const wsOptions = { apiKey: openaiCodexToken, transport: "websocket" as const }; + + it.skipIf(!openaiCodexToken)("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm, wsOptions); + }); + + it.skipIf(!openaiCodexToken)("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm, wsOptions); + }); + + it.skipIf(!openaiCodexToken)("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm, wsOptions); + }); + + it.skipIf(!openaiCodexToken)("should handle thinking with reasoningEffort high", { retry: 3 }, async () => { + await handleThinking(llm, { ...wsOptions, reasoningEffort: "high" }); + }); + + it.skipIf(!openaiCodexToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { ...wsOptions, reasoningEffort: "high" }); + }); + + it.skipIf(!openaiCodexToken)("should handle image input", { retry: 3 }, async () => { + await handleImage(llm, wsOptions); + }); + }); + describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-sonnet-4-5)", () => { const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 87f406dc..b21a75e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +### Added + +- Added `transport` setting (`"sse"`, `"websocket"`, `"auto"`) to `/settings` and `settings.json` for providers that support multiple transports (currently `openai-codex` via OpenAI Codex Responses). + +### Changed + +- Interactive mode now applies transport changes immediately to the active agent session. +- Settings migration now maps legacy `websockets: boolean` to the new `transport` setting. + ## [0.52.11] - 2026-02-13 ### Added diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 4ee5d130..edce2790 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -150,7 +150,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/login`, `/logout` | OAuth authentication | | `/model` | Switch models | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | -| `/settings` | Thinking level, theme, message delivery | +| `/settings` | Thinking level, theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | @@ -193,7 +193,7 @@ Submit messages while the agent is working: - **Escape** aborts and restores queued messages to editor - **Alt+Up** retrieves queued messages back to editor -Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUpMode` can be `"one-at-a-time"` (default, waits for response) or `"all"` (delivers all queued at once). +Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUpMode` can be `"one-at-a-time"` (default, waits for response) or `"all"` (delivers all queued at once). `transport` selects provider transport preference (`"sse"`, `"websocket"`, or `"auto"`) for providers that support multiple transports. --- diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 7bcf58e0..219f5d5f 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -41,7 +41,7 @@ Edit directly or use `/settings` for common options. | `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) | | `quietStartup` | boolean | `false` | Hide startup header | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | -| `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"` or `"fork"` | +| `doubleEscapeAction` | string | `"tree"` | Action for double-escape: `"tree"`, `"fork"`, or `"none"` | | `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) | | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show terminal cursor | @@ -98,12 +98,14 @@ When a provider requests a retry delay longer than `maxDelayMs` (e.g., Google's |---------|------|---------|-------------| | `steeringMode` | string | `"one-at-a-time"` | How steering messages are sent: `"all"` or `"one-at-a-time"` | | `followUpMode` | string | `"one-at-a-time"` | How follow-up messages are sent: `"all"` or `"one-at-a-time"` | +| `transport` | string | `"sse"` | Preferred transport for providers that support multiple transports: `"sse"`, `"websocket"`, or `"auto"` | ### Terminal & Images | Setting | Type | Default | Description | |---------|------|---------|-------------| | `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) | +| `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) | | `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max | | `images.blockImages` | boolean | `false` | Block all images from being sent to LLM | diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 6b191d2c..cadc676d 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -300,6 +300,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }, steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), + transport: settingsManager.getTransport(), thinkingBudgets: settingsManager.getThinkingBudgets(), maxRetryDelayMs: settingsManager.getRetrySettings().maxDelayMs, getApiKey: async (provider) => { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index f4ac02e4..2d6fe85d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,3 +1,4 @@ +import type { Transport } from "@mariozechner/pi-ai"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; @@ -40,6 +41,8 @@ export interface MarkdownSettings { codeBlockIndent?: string; // default: " " } +export type TransportSetting = Transport; + /** * Package source for npm/git packages. * - String form: load all resources from the package @@ -60,6 +63,7 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + transport?: TransportSetting; // default: "sse" steeringMode?: "all" | "one-at-a-time"; followUpMode?: "all" | "one-at-a-time"; theme?: string; @@ -188,6 +192,12 @@ export class SettingsManager { delete settings.queueMode; } + // Migrate legacy websockets boolean -> transport enum + if (!("transport" in settings) && typeof settings.websockets === "boolean") { + settings.transport = settings.websockets ? "websocket" : "sse"; + delete settings.websockets; + } + // Migrate old skills object format to new array format if ( "skills" in settings && @@ -433,6 +443,16 @@ export class SettingsManager { this.save(); } + getTransport(): TransportSetting { + return this.settings.transport ?? "sse"; + } + + setTransport(transport: TransportSetting): void { + this.globalSettings.transport = transport; + this.markModified("transport"); + this.save(); + } + getCompactionEnabled(): boolean { return this.settings.compaction?.enabled ?? true; } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 0c2f9769..f5304f62 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -1,4 +1,5 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; +import type { Transport } from "@mariozechner/pi-ai"; import { Container, getCapabilities, @@ -29,6 +30,7 @@ export interface SettingsConfig { enableSkillCommands: boolean; steeringMode: "all" | "one-at-a-time"; followUpMode: "all" | "one-at-a-time"; + transport: Transport; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; @@ -51,6 +53,7 @@ export interface SettingsCallbacks { onEnableSkillCommandsChange: (enabled: boolean) => void; onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; + onTransportChange: (transport: Transport) => void; onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; @@ -162,6 +165,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.followUpMode, values: ["one-at-a-time", "all"], }, + { + id: "transport", + label: "Transport", + description: "Preferred transport for providers that support multiple transports", + currentValue: config.transport, + values: ["sse", "websocket", "auto"], + }, { id: "hide-thinking", label: "Hide thinking", @@ -354,6 +364,9 @@ export class SettingsSelectorComponent extends Container { case "follow-up-mode": callbacks.onFollowUpModeChange(newValue as "all" | "one-at-a-time"); break; + case "transport": + callbacks.onTransportChange(newValue as Transport); + break; case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 420d3a34..6d504449 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3034,6 +3034,7 @@ export class InteractiveMode { enableSkillCommands: this.settingsManager.getEnableSkillCommands(), steeringMode: this.session.steeringMode, followUpMode: this.session.followUpMode, + transport: this.settingsManager.getTransport(), thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getTheme() || "dark", @@ -3076,6 +3077,10 @@ export class InteractiveMode { onFollowUpModeChange: (mode) => { this.session.setFollowUpMode(mode); }, + onTransportChange: (transport) => { + this.settingsManager.setTransport(transport); + this.session.agent.setTransport(transport); + }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); this.footer.invalidate();