Skip to content

Commit 30d9c19

Browse files
committed
merge fix(server): strip OSC/ANSI escapes from OpenCode CLI (fixes #1)
2 parents 45a2c4b + 4871e96 commit 30d9c19

8 files changed

Lines changed: 139 additions & 14 deletions

File tree

apps/server/src/provider/Layers/OpenCodeAdapter.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import * as Scope from "effect/Scope";
2525
import * as Stream from "effect/Stream";
2626
import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
2727
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
28+
import { sanitizeTerminalValue } from "@t3tools/shared/stripTerminalEscapes";
2829

2930
import { resolveAttachmentPath } from "../../attachmentStore.ts";
3031
import { ServerConfig } from "../../config.ts";
@@ -1472,12 +1473,14 @@ export function makeOpenCodeAdapter(
14721473
});
14731474
}
14741475

1475-
const agent = getModelSelectionStringOptionValue(modelSelection, "agent");
1476-
const variant = getModelSelectionStringOptionValue(modelSelection, "variant");
1476+
const rawAgent = getModelSelectionStringOptionValue(modelSelection, "agent");
1477+
const rawVariant = getModelSelectionStringOptionValue(modelSelection, "variant");
1478+
const agent = rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
const variant = rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
14771480

14781481
context.activeTurnId = turnId;
14791482
context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
1480-
context.activeVariant = variant;
1483+
context.activeVariant = variant || undefined;
14811484
yield* updateProviderSession(
14821485
context,
14831486
{

apps/server/src/provider/Layers/OpenCodeProvider.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import * as Effect from "effect/Effect";
1111

1212
import { createModelCapabilities } from "@t3tools/shared/model";
1313
import { compareSemverVersions } from "@t3tools/shared/semver";
14+
import {
15+
sanitizeTerminalValue,
16+
stripTerminalEscapes,
17+
} from "@t3tools/shared/stripTerminalEscapes";
1418
import {
1519
buildServerProvider,
1620
nonEmptyTrimmed,
@@ -174,14 +178,18 @@ function openCodeCapabilitiesForModel(input: {
174178
readonly model: ProviderListResponse["all"][number]["models"][string];
175179
readonly agents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities {
177-
const variantValues = Object.keys(input.model.variants ?? {});
181+
const variantValues = Object.keys(input.model.variants ?? {}).map(sanitizeTerminalValue);
178182
const defaultVariant = inferDefaultVariant(input.providerID, variantValues);
179183
const variantOptions = variantValues.map((value) =>
180184
defaultVariant === value
181185
? { id: value, label: titleCaseSlug(value), isDefault: true as const }
182186
: { id: value, label: titleCaseSlug(value) },
183187
);
184-
const primaryAgents = input.agents.filter(
188+
const sanitizedAgents = input.agents.map((agent) => ({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
const primaryAgents = sanitizedAgents.filter(
185193
(agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"),
186194
);
187195
const defaultAgent = inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if (versionExit._tag === "Failure") {
391399
return fallback(Cause.squash(versionExit.cause));
392400
}
393-
version = parseGenericCliVersion(versionExit.value.stdout) ?? null;
401+
version = parseGenericCliVersion(stripTerminalEscapes(versionExit.value.stdout)) ?? null;
394402

395403
if (!version) {
396404
return fallback(

apps/server/src/provider/opencodeRuntime.cliParsers.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,30 @@ describe("parseModelsCliOutput", () => {
154154
NodeAssert.equal(model.id, "qwen/qwen3-coder");
155155
NodeAssert.equal(model.providerID, "openrouter");
156156
});
157+
158+
it("strips OSC title escapes from model slugs (opencode CLI leak)", () => {
159+
const stdout = [
160+
"\x1b]0;t3code: ready\x07opencode/big-pickle",
161+
JSON.stringify({ id: "big-pickle", providerID: "opencode", name: "Big Pickle" }),
162+
"\x1b]0;tmp: ready\x07anthropic/claude-sonnet-4-5",
163+
JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }),
164+
].join("\n");
165+
166+
const result = parseModelsCliOutput(stdout);
167+
NodeAssert.equal(result.providers.size, 2);
168+
NodeAssert.ok(result.providers.get("opencode")!.models["big-pickle"]);
169+
NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]);
170+
});
171+
172+
it("strips ANSI escapes from model slugs", () => {
173+
const stdout = [
174+
"\x1b[33mopencode/gpt-5.4\x1b[0m",
175+
JSON.stringify({ id: "gpt-5.4", providerID: "opencode", name: "GPT-5.4" }),
176+
].join("\n");
177+
178+
const result = parseModelsCliOutput(stdout);
179+
NodeAssert.ok(result.providers.get("opencode")!.models["gpt-5.4"]);
180+
});
157181
});
158182

159183
describe("parseAgentListCliOutput", () => {
@@ -255,9 +279,47 @@ describe("parseAgentListCliOutput", () => {
255279
NodeAssert.equal(result[0]!.hidden, true);
256280
NodeAssert.equal(result[1]!.hidden, false);
257281
});
282+
283+
it("strips OSC title escapes leaked by opencode CLI", () => {
284+
// opencode <=1.18 writes `ESC ]0;<cwd>: ready BEL` to stdout for every
285+
// non-help command — even when stdout is a pipe. Without stripping, the
286+
// agent name becomes `ESC]0;...BELbuild` and later fails with
287+
// `Agent not found: "ESC]0;...build"`.
288+
const stdout = [
289+
"\x1b]0;t3code: ready\x07build (primary)",
290+
" " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
291+
"\x1b]0;tmp: ready\x07explore (subagent)",
292+
" " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]),
293+
].join("\n");
294+
295+
const result = parseAgentListCliOutput(stdout);
296+
NodeAssert.equal(result.length, 2);
297+
NodeAssert.equal(result[0]!.name, "build");
298+
NodeAssert.equal(result[0]!.mode, "primary");
299+
NodeAssert.equal(result[1]!.name, "explore");
300+
NodeAssert.equal(result[1]!.mode, "subagent");
301+
});
302+
303+
it("strips ANSI CSI color escapes from agent headers", () => {
304+
const stdout = [
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]),
307+
].join("\n");
308+
309+
const result = parseAgentListCliOutput(stdout);
310+
NodeAssert.equal(result.length, 1);
311+
NodeAssert.equal(result[0]!.name, "build");
312+
});
258313
});
259314

260315
describe("parseSkillsCliOutput", () => {
316+
it("strips OSC escapes before JSON parsing (opencode CLI leak)", () => {
317+
const polluted = "\x1b]0;tmp: ready\x07" + JSON.stringify([{ name: "review-pr", location: "/tmp/x", description: "d", content: "c" }]);
318+
const result = parseSkillsCliOutput(polluted);
319+
NodeAssert.equal(result.length, 1);
320+
NodeAssert.equal(result[0]!.name, "review-pr");
321+
});
322+
261323
it("parses skill metadata from the CLI JSON output", () => {
262324
const result = parseSkillsCliOutput(
263325
JSON.stringify([

apps/server/src/provider/opencodeRuntime.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { collectStreamAsString } from "./providerSnapshot.ts";
3434
import * as NetService from "@t3tools/shared/Net";
3535
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3636
import { resolveSpawnCommand } from "@t3tools/shared/shell";
37+
import { sanitizeTerminalValue, stripTerminalEscapes } from "@t3tools/shared/stripTerminalEscapes";
3738
const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";
3940

@@ -216,7 +217,7 @@ export function parseModelsCliOutput(stdout: string): {
216217
string,
217218
{ id: string; name: string; models: { [key: string]: Model } }
218219
>();
219-
const lines = stdout.split("\n");
220+
const lines = stripTerminalEscapes(stdout).split("\n");
220221
let currentSlug: string | null = null;
221222
const jsonLines: Array<string> = [];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
271272
const agents: Array<Agent> = [];
272-
const lines = stdout.split("\n");
273+
const lines = stripTerminalEscapes(stdout).split("\n");
273274
let currentHeader: { name: string; mode: string } | null = null;
274275
const blockLines: Array<string> = [];
275276

@@ -311,7 +312,8 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {
311312

312313
/** @internal */
313314
export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {
314-
const result = decodeOpenCodeSkillsCliOutputExit(stdout);
315+
const clean = stripTerminalEscapes(stdout);
316+
const result = decodeOpenCodeSkillsCliOutputExit(clean);
315317
return Exit.isSuccess(result) ? result.value : [];
316318
}
317319

apps/server/src/textGeneration/OpenCodeTextGeneration.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
1616
import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1717
import { extractJsonObject } from "@t3tools/shared/schemaJson";
18+
import { sanitizeTerminalValue } from "@t3tools/shared/stripTerminalEscapes";
1819

1920
import * as ServerConfig from "../config.ts";
2021
import { resolveAttachmentPath } from "../attachmentStore.ts";
@@ -408,8 +409,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
408409
cwd: input.cwd,
409410
});
410411
}
411-
const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
412-
const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
412+
const rawAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
413+
const rawVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
414+
const selectedAgent = rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
const selectedVariant = rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
const promptContext = {
414417
operation: input.operation,
415418
cwd: input.cwd,

packages/shared/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@
226226
"./usageFormat": {
227227
"types": "./src/usageFormat.ts",
228228
"import": "./src/usageFormat.ts"
229+
},
230+
"./stripTerminalEscapes": {
231+
"types": "./src/stripTerminalEscapes.ts",
232+
"import": "./src/stripTerminalEscapes.ts"
229233
}
230234
},
231235
"scripts": {

packages/shared/src/model.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
type ProviderOptionSelection,
1111
} from "@t3tools/contracts";
1212

13+
import { sanitizeTerminalValue } from "./stripTerminalEscapes.ts";
14+
1315
const DEFAULT_PROVIDER_DRIVER_KIND = ProviderDriverKind.make("codex");
1416

1517
export interface SelectableModelOption {
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string | undefined {
4749
const value = getProviderOptionSelectionValue(selections, id);
48-
return typeof value === "string" ? value : undefined;
50+
if (typeof value !== "string") return undefined;
51+
const sanitized = sanitizeTerminalValue(value);
52+
return sanitized.length > 0 ? sanitized : undefined;
4953
}
5054

5155
export function getProviderOptionBooleanSelectionValue(
@@ -254,7 +258,7 @@ export function normalizeCustomModelSlug(model: string | null | undefined): stri
254258
return null;
255259
}
256260

257-
return model.trim() || null;
261+
return sanitizeTerminalValue(model) || null;
258262
}
259263

260264
export function resolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
export function trimOrNull<T extends string>(value: T | null | undefined): T | null {
310314
if (typeof value !== "string") return null;
311-
const trimmed = value.trim() as T;
315+
const sanitized = sanitizeTerminalValue(value);
316+
const trimmed = sanitized.trim() as T;
312317
return trimmed || null;
313318
}
314319

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Strip terminal escape sequences from captured CLI stdout.
3+
*
4+
* OpenCode's CLI (and potentially other provider CLIs) can emit OSC title
5+
* sequences (`ESC ]0;<title> BEL` / `ESC \`) and ANSI CSI color codes directly
6+
* to stdout, even when stdout is a pipe. When T3 Code captures that output
7+
* via `ChildProcessSpawner`, those bytes pollute structured parsing — e.g.
8+
* `opencode agent list` becomes `\x1b]0;t3code: ready\x07build (primary)`
9+
* instead of `build (primary)`, causing the agent inventory to store a
10+
* polluted id that later fails with `Agent not found`.
11+
*
12+
* This is defensive for any provider CLI; the regexes are intentionally
13+
* permissive and also handle Ghostty/Zsh title integrations that can leak
14+
* through `shell: true` spawns.
15+
*/
16+
const OSC_RE = /\x1b\].*?(?:\x07|\x1b\\)/g;
17+
const CSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
18+
const CHARSET_RE = /\x1b[()][A-Za-z0-9]/g;
19+
const SINGLE_ESC_RE = /\x1b[@-Z\\-_]/g;
20+
21+
export function stripTerminalEscapes(input: string): string {
22+
if (!input || input.indexOf("\x1b") === -1) {
23+
return input;
24+
}
25+
return input
26+
.replace(OSC_RE, "")
27+
.replace(CSI_RE, "")
28+
.replace(CHARSET_RE, "")
29+
.replace(SINGLE_ESC_RE, "");
30+
}
31+
32+
/**
33+
* Strip escapes and also trim the result. Useful for single-value fields
34+
* like agent/variant names that should never contain control bytes.
35+
*/
36+
export function sanitizeTerminalValue(input: string): string {
37+
return stripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)