From c11a7561cd02f73f08b254bc0fdc2ce96c0df8af Mon Sep 17 00:00:00 2001 From: Radwuan Abouzeid Date: Sat, 5 Sep 2026 16:08:45 -0500 Subject: [PATCH] fix: handle Windows path separators in the TS layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spots assumed POSIX paths and a `/` filesystem root. The Accomplished panel built its filename by splitting on `/`, so a Windows `file_path` was never shortened and the row rendered the whole `C:\Users\x\dev\proj\main.rs` instead of `main.rs`. The basename moves out of listToolEvents into an exported `basename()` — it had to be exported to be testable, which matches how dedupeKey, scopeBySession and latestPerTether are already covered. The one that matters is the root guard in bindSession. It refuses `/` because an untethered session with a rootless cwd would otherwise bind to an arbitrary tab and overwrite its cwd with a key that matches nothing, blanking the panel. A Windows drive root is the identical hazard and walked straight through. The replacement is anchored and exact rather than a prefix test, on purpose: over-rejecting here would silently stop every real Windows session from binding, which is a worse bug and a far quieter one than the drive root the guard exists to catch. So bind-check now covers both directions — five rootless keys that must be refused, and two real Windows project paths that must still bind. Both new cases were run against the old code first, to be sure they could fail: bind-check with "a rootless session (C:\) hijacked the active tab", scope-check with "backslash path not shortened". scope-check was the nearest existing home for the basename cases, so its header widens from "panel session scoping" to "panel row shaping" rather than adding a tenth check script. Out of scope, still Mac-only, tracked in ROADMAP § "Windows port": hook_command's `sh -c`, pty_spawn's `$SHELL`, clipboard.rs's pbpaste, and the Rust-side $HOME lookups (#7). npx tsc --noEmit clean; all nine check scripts pass. Closes #8 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WyeecwdT2gD3fxeWkJpTKa --- scripts/bind-check.ts | 22 ++++++++++++++++++++++ scripts/scope-check.ts | 19 +++++++++++++++++-- src/lib/ingest.ts | 11 +++++++---- src/lib/repo.ts | 10 ++++++++-- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/scripts/bind-check.ts b/scripts/bind-check.ts index 5640f13..6f69c99 100644 --- a/scripts/bind-check.ts +++ b/scripts/bind-check.ts @@ -78,6 +78,28 @@ assert.equal( null, "a rootless session hijacked the active tab" ); +// Same hazard on Windows: a drive root is as rootless as `/`. +for (const root of ["C:\\", "C:/", "C:", "\\", "d:\\"]) { + assert.equal( + bind(ev(), two, { active: "tab-1", projectKey: root }), + null, + `a rootless session (${root}) hijacked the active tab` + ); +} +// ...but a real Windows project must still bind. Over-rejecting here would +// silently stop every Windows session from binding — a worse bug, and a quieter +// one, than the drive root this guard exists to catch. +const win = "C:\\Users\\x\\dev\\proj"; +assert.equal( + bind(ev(), [tab("tab-1", win)], { projectKey: win }), + "tab-1", + "root guard over-rejected a real Windows project path" +); +assert.equal( + bind(ev(), [tab("tab-1", "C:/Users/x/dev/proj")], { projectKey: "C:/Users/x/dev/proj" }), + "tab-1", + "root guard over-rejected a forward-slashed Windows path" +); // A dead tab is never a fallback target. assert.equal( bind(ev(), [{ id: "tab-1", cwd: "/Users/x/dev/other", status: "exited" }], { active: "tab-1" }), diff --git a/scripts/scope-check.ts b/scripts/scope-check.ts index e74389c..f4bcf08 100644 --- a/scripts/scope-check.ts +++ b/scripts/scope-check.ts @@ -1,6 +1,7 @@ -// Self-check for panel session scoping. Run: npm run scope:check +// Self-check for panel row shaping: session scoping + the Accomplished +// panel's filename. Run: npm run scope:check import { strict as assert } from "node:assert"; -import { scopeBySession } from "../src/lib/repo"; +import { basename, scopeBySession } from "../src/lib/repo"; const row = (id: number, sessionId: string) => ({ id, session_id: sessionId }); @@ -25,4 +26,18 @@ assert.deepEqual(scopeBySession(rows, "s-unrelated"), [], "unrelated session mat // fallback, same behavior as before this fix existed. assert.equal(scopeBySession(rows, null).length, 4, "null session id should fall back to the unfiltered list"); +// --- Accomplished panel filename, either separator. --- +// The backslash case is the bug: splitting on `/` alone left the path whole and +// the panel rendered "Edited C:\Users\x\dev\proj\main.rs". +assert.equal(basename("C:\\Users\\x\\dev\\proj\\main.rs"), "main.rs", "backslash path not shortened"); +assert.equal(basename("/Users/x/dev/proj/main.rs"), "main.rs", "posix path not shortened"); +assert.equal(basename("C:/Users/x/dev/proj/main.rs"), "main.rs", "mixed-separator path not shortened"); +// Trailing separator: filter(Boolean) drops the empty tail, so a directory-ish +// path still names its last real segment rather than returning "". +assert.equal(basename("/Users/x/dev/proj/"), "proj"); +// Already bare, and the degenerate inputs — never return "" to the panel. +assert.equal(basename("main.rs"), "main.rs"); +assert.equal(basename("/"), "/"); +assert.equal(basename(""), ""); + console.log("scope-check: all assertions passed"); diff --git a/src/lib/ingest.ts b/src/lib/ingest.ts index f5f834c..2a9e153 100644 --- a/src/lib/ingest.ts +++ b/src/lib/ingest.ts @@ -115,10 +115,13 @@ export function bindSession( } const { boundTabIds, activeTabId, projectKey } = opts; if (!projectKey) return null; - // `/` is never a real project: it means the session was started somewhere with - // no meaningful cwd. Binding it would overwrite the tab's cwd with a key that - // matches nothing, blanking the panel. Untethered + rootless = not ours. - if (projectKey === "/") return null; + // A filesystem root is never a real project: it means the session was started + // somewhere with no meaningful cwd. Binding it would overwrite the tab's cwd + // with a key that matches nothing, blanking the panel. Untethered + rootless = + // not ours. A Windows drive root (`C:\`, `C:/`, bare `C:`) is the same hazard. + // Deliberately anchored and exact — a prefix test would reject `/Users/x` and + // `C:\dev\proj` too, which fails far more quietly than the bug it fixes. + if (/^(?:[/\\]|[A-Za-z]:[/\\]?)$/.test(projectKey)) return null; return ( tabs.find((t) => t.cwd === projectKey && !boundTabIds.has(t.id))?.id ?? tabs.find((t) => t.cwd === projectKey)?.id ?? diff --git a/src/lib/repo.ts b/src/lib/repo.ts index 946d7e9..1fdc8fb 100644 --- a/src/lib/repo.ts +++ b/src/lib/repo.ts @@ -94,6 +94,13 @@ export async function addEvent(sessionId: string, type: string, payloadJson: str ); } +/** Last path segment, for either separator. Agent payloads carry native paths, + * so a Windows `file_path` arrives backslashed — splitting on `/` alone returned + * the whole path and the Accomplished panel printed it verbatim. */ +export function basename(p: string): string { + return p.split(/[\\/]/).filter(Boolean).pop() ?? p; +} + /** Accomplished panel: recent tool uses for a project, straight off the events table. */ export async function listToolEvents(cwd: string, limit = 50): Promise { const d = await getDb(); @@ -111,7 +118,6 @@ export async function listToolEvents(cwd: string, limit = 50): Promise p.split("/").filter(Boolean).pop() ?? p; return rows.map((r) => { let tool = "?"; let detail = ""; @@ -128,7 +134,7 @@ export async function listToolEvents(cwd: string, limit = 50): Promise