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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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*asStreamfrom"effect/Stream";
2626
importtype{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-
constagent=getModelSelectionStringOptionValue(modelSelection,"agent");
1476-
constvariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1476+
constrawAgent=getModelSelectionStringOptionValue(modelSelection,"agent");
1477+
constrawVariant=getModelSelectionStringOptionValue(modelSelection,"variant");
1478+
constagent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
1479+
constvariant=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
readonlymodel: ProviderListResponse["all"][number]["models"][string];
175179
readonlyagents: ReadonlyArray<Agent>;
176180
}): ModelCapabilities{
177-
constvariantValues=Object.keys(input.model.variants??{});
181+
constvariantValues=Object.keys(input.model.variants??{}).map(sanitizeTerminalValue);
178182
constdefaultVariant=inferDefaultVariant(input.providerID,variantValues);
179183
constvariantOptions=variantValues.map((value)=>
180184
defaultVariant===value
181185
? {id: value,label: titleCaseSlug(value),isDefault: trueasconst}
182186
: {id: value,label: titleCaseSlug(value)},
183187
);
184-
constprimaryAgents=input.agents.filter(
188+
constsanitizedAgents=input.agents.map((agent)=>({
189+
...agent,
190+
name: sanitizeTerminalValue(agent.name),
191+
}));
192+
constprimaryAgents=sanitizedAgents.filter(
185193
(agent)=>!agent.hidden&&(agent.mode==="primary"||agent.mode==="all"),
186194
);
187195
constdefaultAgent=inferDefaultAgent(primaryAgents);
@@ -390,7 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu
390398
if(versionExit._tag==="Failure"){
391399
returnfallback(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
returnfallback(

‎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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
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+
constresult=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+
conststdout=[
305+
"\x1b[31mbuild (primary)\x1b[0m",
306+
" "+JSON.stringify([{permission: "*",action: "allow",pattern: "*"}]),
307+
].join("\n");
308+
309+
constresult=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+
constpolluted="\x1b]0;tmp: ready\x07"+JSON.stringify([{name: "review-pr",location: "/tmp/x",description: "d",content: "c"}]);
318+
constresult=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
constresult=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*asNetServicefrom"@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
constencodeUnknownJsonStringExit=Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
3839
constOPENCODE_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-
constlines=stdout.split("\n");
220+
constlines=stripTerminalEscapes(stdout).split("\n");
220221
letcurrentSlug: string|null=null;
221222
constjsonLines: Array<string>=[];
222223

@@ -269,7 +270,7 @@ export function parseModelsCliOutput(stdout: string): {
269270
/** @internal */
270271
exportfunctionparseAgentListCliOutput(stdout: string): ReadonlyArray<Agent>{
271272
constagents: Array<Agent>=[];
272-
constlines=stdout.split("\n");
273+
constlines=stripTerminalEscapes(stdout).split("\n");
273274
letcurrentHeader: {name: string;mode: string}|null=null;
274275
constblockLines: Array<string>=[];
275276

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

312313
/** @internal */
313314
exportfunctionparseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill>{
314-
constresult=decodeOpenCodeSkillsCliOutputExit(stdout);
315+
constclean=stripTerminalEscapes(stdout);
316+
constresult=decodeOpenCodeSkillsCliOutputExit(clean);
315317
returnExit.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*asServerConfigfrom"../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-
constselectedAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
412-
constselectedVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
412+
constrawAgent=getModelSelectionStringOptionValue(input.modelSelection,"agent");
413+
constrawVariant=getModelSelectionStringOptionValue(input.modelSelection,"variant");
414+
constselectedAgent=rawAgent ? sanitizeTerminalValue(rawAgent) : undefined;
415+
constselectedVariant=rawVariant ? sanitizeTerminalValue(rawVariant) : undefined;
413416
constpromptContext={
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
typeProviderOptionSelection,
1111
}from"@t3tools/contracts";
1212

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

1517
exportinterfaceSelectableModelOption{
@@ -45,7 +47,9 @@ export function getProviderOptionStringSelectionValue(
4547
id: string,
4648
): string|undefined{
4749
constvalue=getProviderOptionSelectionValue(selections,id);
48-
returntypeofvalue==="string" ? value : undefined;
50+
if(typeofvalue!=="string")returnundefined;
51+
constsanitized=sanitizeTerminalValue(value);
52+
returnsanitized.length>0 ? sanitized : undefined;
4953
}
5054

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

257-
returnmodel.trim()||null;
261+
returnsanitizeTerminalValue(model)||null;
258262
}
259263

260264
exportfunctionresolveSelectableModel(
@@ -308,7 +312,8 @@ export function resolveModelSlugForProvider(
308312
/** Trim a string, returning null for empty/missing values. */
309313
exportfunctiontrimOrNull<Textendsstring>(value: T|null|undefined): T|null{
310314
if(typeofvalue!=="string")returnnull;
311-
consttrimmed=value.trim()asT;
315+
constsanitized=sanitizeTerminalValue(value);
316+
consttrimmed=sanitized.trim()asT;
312317
returntrimmed||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+
constOSC_RE=/\x1b\].*?(?:\x07|\x1b\\)/g;
17+
constCSI_RE=/\x1b\[[0-9;?]*[-/]*[@-~]/g;
18+
constCHARSET_RE=/\x1b[()][A-Za-z0-9]/g;
19+
constSINGLE_ESC_RE=/\x1b[@-Z\\-_]/g;
20+
21+
exportfunctionstripTerminalEscapes(input: string): string{
22+
if(!input||input.indexOf("\x1b")===-1){
23+
returninput;
24+
}
25+
returninput
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+
exportfunctionsanitizeTerminalValue(input: string): string{
37+
returnstripTerminalEscapes(input).trim();
38+
}

0 commit comments

Comments
 (0)