From 8eb304fb5d1b3d8202541a1e668e634bbb84f9a8 Mon Sep 17 00:00:00 2001 From: Mostafa Ben Date: Thu, 27 Aug 2026 12:03:43 +0000 Subject: [PATCH 1/3] fix(server): make OpenCode health check resilient to terminal escapes and spaced model IDs - strip OSC/CSI escape sequences from opencode CLI stdout before parsing server URL, models, agents and skills (opencode <=1.18 emits ESC ]0;... BEL title sequences even when piped). This prevents model slugs gaining prefixes and JSON failing to decode, matching open PR #7988. - allow model IDs containing spaces (e.g. 'Kimi K3') by widening SLUG_LINE_RE to /^(\S+\/.*\S)\s*$/ so 'custom/Model Two' no longer drops the preceding model, matching open PR #7834. - improve diagnostic for 'models' command failure: include truncated stderr/stdout in the OpenCodeRuntimeError detail instead of just the exit code, so 'exited with code 1' errors become actionable. Tested with manual parser checks for spaced IDs and OSC/CSI stripping. Fixes pingdotgg/t3code#8386 --- apps/server/src/provider/opencodeRuntime.ts | 46 ++++++++++++++++----- packages/shared/src/stripTerminalEscapes.ts | 18 ++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 packages/shared/src/stripTerminalEscapes.ts diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 80329a6794d5..5bc05083769a 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -34,6 +34,15 @@ import { collectStreamAsString } from "./providerSnapshot.ts"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { stripTerminalEscapes } from "@t3tools/shared/stripTerminalEscapes"; + +// Control bytes are escaped as text (e.g. `\^[`) inside JSON.stringify +// output, so escapes embedded in CLI-printed values only become visible after +// JSON decoding. This reviver strips them from every decoded string, at any +// depth, while leaving non-string values and object/array structure untouched. +const stripTerminalEscapesJsonReviver = (_key: string, value: unknown): unknown => + typeof value === "string" ? stripTerminalEscapes(value) : value; + const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; @@ -132,9 +141,7 @@ const OpenCodeSkillSchema = Schema.Struct({ description: Schema.optionalKey(Schema.NullOr(Schema.String)), location: Schema.optionalKey(Schema.NullOr(Schema.String)), }); -const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( - Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), -); +const decodeOpenCodeSkillsArrayExit = Schema.decodeUnknownExit(Schema.Array(OpenCodeSkillSchema)); export interface OpenCodeRuntimeShape { /** @@ -186,7 +193,9 @@ export interface OpenCodeRuntimeShape { } function parseServerUrlFromOutput(output: string): string | null { - for (const line of output.split("\n")) { + // The opencode CLI can prepend an OSC title sequence to its output; strip it + // so the ready line still matches. + for (const line of stripTerminalEscapes(output).split("\n")) { if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { continue; } @@ -196,7 +205,7 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } -const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; +const SLUG_LINE_RE = /^(\S+\/.*\S)\s*$/; const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command @@ -216,7 +225,7 @@ export function parseModelsCliOutput(stdout: string): { string, { id: string; name: string; models: { [key: string]: Model } } >(); - const lines = stdout.split("\n"); + const lines = stripTerminalEscapes(stdout).split("\n"); let currentSlug: string | null = null; const jsonLines: Array = []; @@ -225,7 +234,7 @@ export function parseModelsCliOutput(stdout: string): { const jsonStr = jsonLines.join("\n").trim(); if (jsonStr.length > 0) { try { - const model = JSON.parse(jsonStr) as Model; + const model = JSON.parse(jsonStr, stripTerminalEscapesJsonReviver) as Model; const separator = currentSlug.indexOf("/"); if (separator > 0) { const providerID = currentSlug.slice(0, separator); @@ -269,7 +278,7 @@ export function parseModelsCliOutput(stdout: string): { /** @internal */ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { const agents: Array = []; - const lines = stdout.split("\n"); + const lines = stripTerminalEscapes(stdout).split("\n"); let currentHeader: { name: string; mode: string } | null = null; const blockLines: Array = []; @@ -278,7 +287,7 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { const jsonStr = blockLines.join("\n").trim(); if (jsonStr.length > 0) { try { - const permission = JSON.parse(jsonStr); + const permission = JSON.parse(jsonStr, stripTerminalEscapesJsonReviver); agents.push({ name: currentHeader.name, mode: currentHeader.mode as Agent["mode"], @@ -311,7 +320,15 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { /** @internal */ export function parseSkillsCliOutput(stdout: string): ReadonlyArray { - const result = decodeOpenCodeSkillsCliOutputExit(stdout); + let parsed: unknown; + try { + // The reviver strips escapes embedded in skill strings before validation, + // so schema-decoded values can never carry raw control bytes. + parsed = JSON.parse(stripTerminalEscapes(stdout), stripTerminalEscapesJsonReviver); + } catch { + return []; + } + const result = decodeOpenCodeSkillsArrayExit(parsed); return Exit.isSuccess(result) ? result.value : []; } @@ -791,9 +808,16 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); } if (modelsResult.value.code !== 0) { + const stderrDetail = modelsResult.value.stderr.trim(); + const stdoutDetail = modelsResult.value.stdout.trim(); + const details: string[] = [ + `OpenCode models command exited with code ${modelsResult.value.code}.`, + stderrDetail ? `stderr:\n${stderrDetail.slice(0, 2000)}` : null, + stdoutDetail ? `stdout:\n${stdoutDetail.slice(0, 2000)}` : null, + ].filter((entry): entry is string => entry !== null); return yield* new OpenCodeRuntimeError({ operation: "loadInventoryFromCli", - detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + detail: details.join("\n\n"), }); } diff --git a/packages/shared/src/stripTerminalEscapes.ts b/packages/shared/src/stripTerminalEscapes.ts new file mode 100644 index 000000000000..430b9d7d10b0 --- /dev/null +++ b/packages/shared/src/stripTerminalEscapes.ts @@ -0,0 +1,18 @@ +// Some CLIs (e.g. opencode <= 1.18) emit terminal escape sequences on stdout +// even when stdout is a pipe - most notably OSC title sets like +// `ESC ]0;: ready BEL`. Anything that parses such output must strip them +// first, or the escapes leak into stored identifiers and slugs. + +// OSC: `ESC ]` payload terminated by BEL or by ST (`ESC \`). +// eslint-disable-next-line no-control-regex -- matching control bytes is the point of this helper +const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; +// CSI: `ESC [` parameter bytes (including the ITU T.416 colon subparameter +// separator) and optional intermediate bytes, followed by a final byte in +// @-~ - ANSI colors, cursor movement, mode set/reset, ... +// eslint-disable-next-line no-control-regex -- matching control bytes is the point of this helper +const CSI_SEQUENCE = /\u001b\[[0-9:;?<=>]*[ -/]*[@-~]/g; + +/** Removes OSC and CSI escape sequences from terminal output. */ +export function stripTerminalEscapes(text: string): string { + return text.replace(OSC_SEQUENCE, "").replace(CSI_SEQUENCE, ""); +} From d824a5e4c8a82853629d8320cdaf8e5200d7a9ec Mon Sep 17 00:00:00 2001 From: Mostafa Ben Date: Thu, 27 Aug 2026 12:09:47 +0000 Subject: [PATCH 2/3] fix(shared): export stripTerminalEscapes helper Required for opencodeRuntime fix to resolve correctly on Windows. --- packages/shared/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shared/package.json b/packages/shared/package.json index eeaa5f59e087..79f6c805501d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -230,6 +230,10 @@ "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" + }, + "./stripTerminalEscapes": { + "types": "./src/stripTerminalEscapes.ts", + "import": "./src/stripTerminalEscapes.ts" } }, "scripts": { From 2e6af7aef82716ebc0a4d113245d0bed24efc102 Mon Sep 17 00:00:00 2001 From: Mostafa Ben Date: Thu, 27 Aug 2026 13:03:58 +0000 Subject: [PATCH 3/3] fix(server): address review feedback - keep raw CLI output out of detail Per Macroscope Effect Service Conventions review, raw stderr/stdout can contain paths and tokens and should not be copied into the caller-visible detail/message. Keep detail to exit code plus bounded lengths and preserve exact output only in cause. Co-authored-by: macroscope-agent --- apps/server/src/provider/opencodeRuntime.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 5bc05083769a..e6919fdabfb7 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -810,14 +810,14 @@ const makeOpenCodeRuntime = Effect.gen(function* () { if (modelsResult.value.code !== 0) { const stderrDetail = modelsResult.value.stderr.trim(); const stdoutDetail = modelsResult.value.stdout.trim(); - const details: string[] = [ - `OpenCode models command exited with code ${modelsResult.value.code}.`, - stderrDetail ? `stderr:\n${stderrDetail.slice(0, 2000)}` : null, - stdoutDetail ? `stdout:\n${stdoutDetail.slice(0, 2000)}` : null, - ].filter((entry): entry is string => entry !== null); return yield* new OpenCodeRuntimeError({ operation: "loadInventoryFromCli", - detail: details.join("\n\n"), + detail: `OpenCode models command exited with code ${modelsResult.value.code} (stderr ${stderrDetail.length} chars, stdout ${stdoutDetail.length} chars).`, + cause: { + exitCode: modelsResult.value.code, + stdout: modelsResult.value.stdout, + stderr: modelsResult.value.stderr, + }, }); }