From b22c7345ea4197fc3d93a2cf2f56f099851d35f4 Mon Sep 17 00:00:00 2001 From: spaceantdev Date: Sat, 10 Oct 2026 12:17:24 +0100 Subject: [PATCH 1/2] fix(chat): resolve Codex transcripts across Windows path casing --- CHANGELOG.md | 3 +++ server/codex.test.ts | 16 ++++++++++++++++ server/codex.ts | 17 +++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77d5530f6a..b7ec97895f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,9 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ([#724](https://github.com/devswha/herdr-web-ui/pull/724)) ### Fixed +- Codex conversations on Windows resolve the transcript even when the pane's working directory + uses different letter casing from Codex's stored path. The lookup verifies the directory on + disk before considering its canonical spelling, so another pane's chat is not substituted. - The browser demo keeps each row's own split when resizing a grid and keeps deeply split panes visible and resizable. ([#726](https://github.com/devswha/herdr-web-ui/pull/726)) - Workspace and tab changes, pane moves and layout updates made in herdr's terminal UI diff --git a/server/codex.test.ts b/server/codex.test.ts index 4a5d0d580b..a6f91fae64 100644 --- a/server/codex.test.ts +++ b/server/codex.test.ts @@ -462,6 +462,22 @@ describe("Codex rollout resolution", () => { } finally { db.close(); } }); + it.skipIf(process.platform !== "win32")("finds Codex's canonical casing when Herdr reports another spelling of the same directory", () => { + const root = mkdtempSync(join(tmpdir(), "herdr-codex-cwd-")); + const actual = join(root, "Recorde"); + mkdirSync(actual); + const reported = join(root, "recorde"); + const canonical = realpathSync.native(reported); + const db = new Database(":memory:"); + try { + db.run("CREATE TABLE threads (id TEXT, cwd TEXT)"); + db.run("INSERT INTO threads VALUES (?, ?)", ["own", `\\\\?\\${canonical}`]); + const { where, params } = storedCwdCondition(reported); + expect(db.query<{ id: string }, string[]>(`SELECT id FROM threads WHERE ${where}`).all(...params)).toEqual([{ id: "own" }]); + expect(sameDirectory(reported, canonical)).toBe(true); + } finally { db.close(); rmSync(root, { recursive: true, force: true }); } + }); + it("looks threads up on Codex's binary cwd index, not by a scan of every thread (#587)", () => { const db = threadsDb(); try { diff --git a/server/codex.ts b/server/codex.ts index 7381695d68..36373281b8 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -466,7 +466,13 @@ export function directoryKey(cwd: string): string { /** Whether two cwds are one directory by `directoryKey`; POSIX ones only when they are equal. */ export function sameDirectory(left: string, right: string): boolean { - return directoryKey(left) === directoryKey(right); + if (directoryKey(left) === directoryKey(right)) return true; + if (process.platform !== "win32") return false; + try { + const a = statSync(left); + const b = statSync(right); + return a.isDirectory() && b.isDirectory() && a.dev === b.dev && a.ino === b.ino; + } catch { return false; } } /** The other Codex panes herdr shows in this pane's directory, in any spelling `sameDirectory` takes. */ @@ -481,7 +487,14 @@ export function codexPeersIn(panes: readonly HerdrPane[], paneId: string, cwd: s * stays on Codex's own binary cwd index. */ export function storedCwdCondition(cwd: string): { where: string; params: string[] } { - const params = [...new Set(storedCwds(cwd).flatMap((spelling) => { + // Windows resolves an existing directory to its on-disk casing. Herdr may report the + // shell's spelling while Codex stores the canonical spelling in its binary cwd index. + // Keep both: the original is needed when Codex recorded it before a rename or via a link. + let canonical = cwd; + if (process.platform === "win32") { + try { canonical = realpathSync.native(cwd); } catch { /* A removed cwd still has its reported spelling. */ } + } + const params = [...new Set([cwd, canonical].flatMap((path) => storedCwds(path)).flatMap((spelling) => { const drive = /^((?:\\\\\?\\)?)([A-Za-z])(:\\.*)$/s.exec(spelling); return drive ? [`${drive[1]}${drive[2]!.toUpperCase()}${drive[3]}`, `${drive[1]}${drive[2]!.toLowerCase()}${drive[3]}`] : [spelling]; }))]; From fbf63aa745a90da70c5688d9178f707c4cd5cd8e Mon Sep 17 00:00:00 2001 From: devswha Date: Sat, 10 Oct 2026 21:48:03 +0900 Subject: [PATCH 2/2] fix(chat): compare Windows directory IDs as bigint and test on Windows CI - statSync with bigint: Windows file IDs are 64-bit and can round to one number. - codexPeersIn checks the agent before the directory, so only Codex panes stat. - Move the casing regression into windows-native.test.ts, which the Windows jobs run, and assert the two spellings differ by directoryKey. - CHANGELOG: link the PR and credit the author. Found by CodeRabbit and Codex review. --- CHANGELOG.md | 1 + server/codex.test.ts | 16 ---------------- server/codex.ts | 10 ++++++---- server/windows-native.test.ts | 23 ++++++++++++++++++++++- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ec97895f..d7bacf2ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - Codex conversations on Windows resolve the transcript even when the pane's working directory uses different letter casing from Codex's stored path. The lookup verifies the directory on disk before considering its canonical spelling, so another pane's chat is not substituted. + ([#740](https://github.com/devswha/herdr-web-ui/pull/740) by @antonydoyle) - The browser demo keeps each row's own split when resizing a grid and keeps deeply split panes visible and resizable. ([#726](https://github.com/devswha/herdr-web-ui/pull/726)) - Workspace and tab changes, pane moves and layout updates made in herdr's terminal UI diff --git a/server/codex.test.ts b/server/codex.test.ts index a6f91fae64..4a5d0d580b 100644 --- a/server/codex.test.ts +++ b/server/codex.test.ts @@ -462,22 +462,6 @@ describe("Codex rollout resolution", () => { } finally { db.close(); } }); - it.skipIf(process.platform !== "win32")("finds Codex's canonical casing when Herdr reports another spelling of the same directory", () => { - const root = mkdtempSync(join(tmpdir(), "herdr-codex-cwd-")); - const actual = join(root, "Recorde"); - mkdirSync(actual); - const reported = join(root, "recorde"); - const canonical = realpathSync.native(reported); - const db = new Database(":memory:"); - try { - db.run("CREATE TABLE threads (id TEXT, cwd TEXT)"); - db.run("INSERT INTO threads VALUES (?, ?)", ["own", `\\\\?\\${canonical}`]); - const { where, params } = storedCwdCondition(reported); - expect(db.query<{ id: string }, string[]>(`SELECT id FROM threads WHERE ${where}`).all(...params)).toEqual([{ id: "own" }]); - expect(sameDirectory(reported, canonical)).toBe(true); - } finally { db.close(); rmSync(root, { recursive: true, force: true }); } - }); - it("looks threads up on Codex's binary cwd index, not by a scan of every thread (#587)", () => { const db = threadsDb(); try { diff --git a/server/codex.ts b/server/codex.ts index 36373281b8..adee438f70 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -469,16 +469,18 @@ export function sameDirectory(left: string, right: string): boolean { if (directoryKey(left) === directoryKey(right)) return true; if (process.platform !== "win32") return false; try { - const a = statSync(left); - const b = statSync(right); + // bigint: a Windows file ID is 64-bit, and two of them can round to one number + const a = statSync(left, { bigint: true }); + const b = statSync(right, { bigint: true }); return a.isDirectory() && b.isDirectory() && a.dev === b.dev && a.ino === b.ino; } catch { return false; } } /** The other Codex panes herdr shows in this pane's directory, in any spelling `sameDirectory` takes. */ export function codexPeersIn(panes: readonly HerdrPane[], paneId: string, cwd: string): HerdrPane[] { - return panes.filter((pane) => pane.pane_id !== paneId && pane.cwd != null && sameDirectory(pane.cwd, cwd) - && (pane.agent ?? pane.agent_session?.agent) === "codex"); + // the agent first: sameDirectory can ask the file system, and only Codex panes need it + return panes.filter((pane) => pane.pane_id !== paneId && (pane.agent ?? pane.agent_session?.agent) === "codex" + && pane.cwd != null && sameDirectory(pane.cwd, cwd)); } /** diff --git a/server/windows-native.test.ts b/server/windows-native.test.ts index 99b273f5ab..fd5ea8d6fe 100644 --- a/server/windows-native.test.ts +++ b/server/windows-native.test.ts @@ -11,7 +11,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { claudeProcessSession, forgetClaudeSessions, processClaudeConfigDir } from "./claude-store.ts"; -import { codexRolloutPath } from "./codex.ts"; +import { Database } from "bun:sqlite"; + +import { codexRolloutPath, directoryKey, sameDirectory, storedCwdCondition } from "./codex.ts"; import { gjcSessionFile, storeRelative } from "./gjc-runtime.ts"; import { descendantArgv, windowsProcessTable } from "./windows-processes.ts"; import { windowsHost } from "./remote-host.ts"; @@ -122,6 +124,25 @@ it.skipIf(!onWindows)("reads a Codex rollout path stored with the \\\\?\\ prefix } finally { rmSync(root, { recursive: true, force: true }); } }); +it.skipIf(!onWindows)("finds Codex's thread under the on-disk casing when herdr reports another spelling of the directory (#740)", () => { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "herdr-codex-cwd-"))); + mkdirSync(join(root, "Recorde")); + const reported = join(root, "recorde"); + const canonical = realpathSync.native(reported); + const db = new Database(":memory:"); + try { + // the two spellings are only one directory to the file system, not to directoryKey + expect(directoryKey(reported)).not.toBe(directoryKey(canonical)); + db.run("CREATE TABLE threads (id TEXT, cwd TEXT)"); + db.run("INSERT INTO threads VALUES (?, ?)", ["own", `\\\\?\\${canonical}`]); + const { where, params } = storedCwdCondition(reported); + expect(db.query<{ id: string }, string[]>(`SELECT id FROM threads WHERE ${where}`).all(...params)).toEqual([{ id: "own" }]); + expect(sameDirectory(reported, canonical)).toBe(true); + mkdirSync(join(root, "Other")); + expect(sameDirectory(reported, join(root, "Other"))).toBe(false); + } finally { db.close(); rmSync(root, { recursive: true, force: true }); } +}); + it.skipIf(!onWindows)("finds a Claude's own store from the start it records, which the process table repeats", async () => { const home = realpathSync(mkdtempSync(join(tmpdir(), "herdr-claude-native-"))); const serverConfigDir = process.env["CLAUDE_CONFIG_DIR"];