[BUG] VS Code ext >=2.1.269: session history empty for RTL (Hebrew/Arabic) usernames — `claude auth status --json` reverses the config path (rendered via Ink) and the extension follows it
Claude Code version: VS Code extension anthropic.claude-code 2.1.289 (win32-x64) with its bundled native CLI 2.1.289. The CLI-side reversal also reproduces with CLI 2.1.267.
Is this a regression? Yes. Extension 2.1.267 works; 2.1.269, 2.1.282, 2.1.288 and 2.1.289 do not. Last working version: 2.1.267.
Platform: Windows 11 Pro 10.0.26200, VS Code 1.140.0, Windows profile folder name in Hebrew.
Related: #97288 (same root cause: RTL username reversed in the config path; commenters there also report sessions not resuming) and #94253 (same symptom: history empty after 2.1.270, fixed by downgrading; likely the same cause when the profile folder name contains RTL characters).
claude auth status --json prints the config directory with the Hebrew segment reversed, and since 2.1.269/2.1.270 the VS Code extension follows that configDirectory, so it lists sessions from a folder that does not exist. 2.1.267 ignored the field and still works.
Environment
| Item | Value |
|---|---|
| OS | Windows 11 Pro 10.0.26200 |
| Windows user / profile folder | לייבי → C:\Users\לייבי (Hebrew, RTL) |
| VS Code | 1.140.0 |
| Extension (broken) | anthropic.claude-code 2.1.269, 2.1.282, 2.1.288, 2.1.289 |
| Extension (works) | anthropic.claude-code 2.1.267 |
| CLI | 2.1.289 native (~/.local/bin/claude.exe), also the binaries bundled in the extensions |
Symptom
In the extension's session list, history for the workspace (e.g. 33 sessions of c:\Projects\korg) appears for a moment on startup and then disappears. Downgrading the extension to 2.1.267 brings it back. No sessions are lost on disk: every version writes to C:\Users\לייבי\.claude\projects\c--Projects-korg\*.jsonl.
Root cause (three pieces)
1. The CLI reverses RTL text in auth status --json
$ claude auth status --json
{
"loggedIn": true,
"projectsDirectory": "C:\\Users\\יבייל\\.claude\\projects",
"configDirectory": "C:\\Users\\יבייל\\.claude",
...
}
The real folder is לייבי; the output says יבייל. This is in the bytes, not a terminal rendering effect:
$ claude auth status --json | grep configDirectory | od -c
... U s e r s \ \ 327 231 327 221 327 231 327 231 327 234 \ \ . c l a u d e
$ printf 'לייבי' | od -c
327 234 327 231 327 231 327 221 327 231
It is a Unicode-bidi visual reordering of the whole RTL run, separators included. With CLAUDE_CONFIG_DIR=C:/Users/לייבי/שלום-עולם the output becomes C:/Users/םלוע-םולש/יבייל. The reason: the auth status command renders its JSON through the Ink/TUI renderer (d.render(e(MA,{children: JSON.stringify(s,null,2)}))), which applies the TUI's RTL display logic. Messages written directly to stderr (e.g. claude mcp get לייבי) are not reversed. The 2.1.267 CLI has the same behaviour (it already reversed projectsDirectory), so the CLI side is old; the extension side is what changed.
2. The extension (>= 2.1.269) follows the reported configDirectory without checking it exists
In extension.js (2.1.289), the claude auth status --json probe result is parsed and handed to lq1:
// extension.js 2.1.289 (minified names)
function zz(){ if(process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
return L4.join(BV.homedir(), ".claude") }
function Q6(){ return nc ?? zz() } // config root used everywhere
function c2($){ /* absolute-path check */ return L4.resolve($) }
function ZR($,J){ return ic($)===ic(J) } // resolve + NFC compare
function lq1($,J){ pq1=!0; let Q=Q6(), X=c2($);
if(nc = X!==void 0 && !ZR(X, zz()) ? X : void 0, ZR(Q6(),Q)) return; // <-- no existsSync
try{ cq1.emit() } catch(Y){ ... } }
...
else if (typeof L==="object" && L!==null && "configDirectory" in L) N={value:L.configDirectory}
...
if (N!==void 0) lq1(N.value, (L)=>this.logger.error(`Failed to follow a moved config home: ${L}`))
C:\Users\יבייל\.claude is a valid absolute path that differs from the Node-computed default, so nc is set to it and Q6() now returns a directory that does not exist.
3. Session listing reads from Q6()/projects/<slug> → empty
readSessionList() returns folderKey: ic(join(Q6(),"projects")) and the SDK listing uses the same root. The webview first lists sessions (correct root), then sends await_config_home_request, the probe finishes, cq1.emit() fires, the list is re-requested from the bogus root → empty. That is the "flashes then disappears" behaviour; the extension log shows exactly that sequence:
01:27:44.589 list_sessions_request <- sessions visible
01:27:44.776 Loading config cache by launching Claude (no channel)...
01:27:46.390 await_config_home_request
01:27:46.435 list_sessions_request <- now empty
Minimal reproduction
- Windows account whose profile folder contains Hebrew/Arabic letters (e.g.
C:\Users\לייבי). claude auth status --json→configDirectoryhas the RTL segment reversed.- Install extension 2.1.289, open a workspace with existing sessions → list is empty after startup.
A Node script that mirrors the extension logic (zz/ic/ZR/c2/lq1 + listing) is included at the bottom of this issue:
$ node repro/simulate-extension.js "%USERPROFILE%\.local\bin\claude.exe" "c:\Projects\korg"
"cliReported": "C:\\Users\\יבייל\\.claude",
"followed": "C:\\Users\\יבייל\\.claude",
"listingFrom": "C:\\Users\\יבייל\\.claude\\projects\\c--Projects-korg",
"sessionsFound": "DIR DOES NOT EXIST"
$ set CLAUDE_CONFIG_DIR=C:\Users\05A7~1\.claude (ASCII 8.3 alias of the same folder)
$ node repro/simulate-extension.js ...
"cliReported": "C:\\Users\\05A7~1\\.claude",
"followed": "(none - stays on default)",
"sessionsFound": 33
Suggested fixes
- CLI: machine-readable output (
--json,--output-format json/stream-json, anything consumed by the SDK/extension) must be written straight to stdout and never go through the Ink renderer / RTL visual reordering. More generally, bidi visual reordering should only ever be applied when stdout is a TTY. - Extension: in
lq1, only follow aconfigDirectorythatfs.existsSync()(or better: that containsprojects/), otherwise keep the default and log a warning. Also consider NFC/NFD-insensitive comparison for macOS. - Defensive: when the probe's path differs from the local default only in character order of a non-ASCII run, treat it as a rendering artefact.
Workaround (confirmed)
Point CLAUDE_CONFIG_DIR at an ASCII alias of the same folder, e.g. the 8.3 short name C:\Users\05A7~1\.claude (or a junction with an ASCII name), copy ~\.claude.json into that directory, and restart VS Code. The CLI then reports the path correctly and the extension finds the sessions.
Side notes seen while debugging
C:\Userscontains mojibake folders (UTF-8 bytes of Hebrew names re-decoded as cp1255), created by some process at an earlier date; probably unrelated but in the same family of bugs.
Repro script (simulate-extension.js)
<details><summary>Node script mirroring the extension logic (zz / ic / ZR / c2 / lq1 + listing)</summary>
// Faithful simulation of the 2.1.289 VS Code extension's "follow the CLI-reported config home"
// logic (functions zz / ic / ZR / c2 / lq1 in extension.js) followed by the project session listing.
const fs = require('fs'), path = require('path'), os = require('os'), cp = require('child_process');
const CLI = process.argv[2], cwd = process.argv[3];
const zz = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); // extension: zz()
const ic = (p) => path.resolve(p).normalize('NFC'); // extension: ic()
const ZR = (a, b) => ic(a) === ic(b); // extension: ZR()
const WIN_ABS = /^([A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/])/; // extension: c2() regex
const c2 = (p) => (typeof p === 'string' && path.isAbsolute(p) && WIN_ABS.test(p)) ? path.resolve(p) : undefined;
const slug = (p) => p.replace(/[^a-zA-Z0-9]/g, '-');
const probe = cp.spawnSync(CLI, ['auth', 'status', '--json'], { encoding: 'utf8', cwd, env: process.env, windowsHide: true });
const reported = JSON.parse(probe.stdout).configDirectory;
const X = c2(reported);
const nc = (X !== undefined && !ZR(X, zz())) ? X : undefined; // extension: lq1()
const Q6 = nc ?? zz(); // extension: Q6()
const projectDir = path.join(Q6, 'projects', slug(cwd)); // extension: LR() + slug
let sessionsFound = 'DIR DOES NOT EXIST';
try { sessionsFound = fs.readdirSync(projectDir).filter((f) => f.endsWith('.jsonl')).length; } catch {}
console.log(JSON.stringify({
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR ?? null,
nodeDefault: zz(),
cliReported: reported,
regexOk: WIN_ABS.test('C:\\x'),
followed: nc ?? '(none - stays on default)',
listingFrom: projectDir,
sessionsFound,
}, null, 2));
</details>