diff --git a/docs/explore-notes/conpty-sessions.md b/docs/explore-notes/conpty-sessions.md index 9fc9a1dc..40619768 100644 --- a/docs/explore-notes/conpty-sessions.md +++ b/docs/explore-notes/conpty-sessions.md @@ -54,8 +54,16 @@ so a token like `"C:\repo\"` never closes and every following argument is swallo preceding **variadic** flag. A list working dir stored as `C:\Dev\Repos\Bandel.Hub\` therefore fed `--add-dir` the repo, `--append-system-prompt-file`, its value **and** the positional kickoff: the CLI warned `brief.md is not a directory` and the session opened with no prompt at all -(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` now run the repo -through `TrimTrailingSeparator`; session dirs the worker builds never carry one. +(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` run the repo through +`TrimTrailingSeparator`; session dirs the worker builds never carry one. + +The same data bit the UI's "Open in terminal" on 2026-08-10 (`wt -d "C:\…\StaplerTracking\"` → +`Could not access starting directory "C:\…\StaplerTracking""`), so the fix moved to the write side: +**`ListRepository.AddAsync`/`UpdateAsync` normalize `WorkingDir` via `Paths.TrimTrailingSeparator`**, +which covers every writer (UI create, repo import, hub `UpdateList`, MCP `CreateList`/`UpdateList`) — +the UI and worker keep their own defensive trim for rows written before that. Anything new that puts +a **user-supplied** path on a command line should use `Paths.TrimTrailingSeparator` and, on the +`ProcessStartInfo` side, `ArgumentList` rather than an interpolated `Arguments` string. Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so every repro passes. Read the real command line instead: diff --git a/src/ClaudeDo.Data/Paths.cs b/src/ClaudeDo.Data/Paths.cs index 093caa4f..1c656834 100644 --- a/src/ClaudeDo.Data/Paths.cs +++ b/src/ClaudeDo.Data/Paths.cs @@ -25,6 +25,22 @@ public static class Paths return Path.GetFullPath(expanded); } + /// + /// Strips trailing directory separators off a path bound for a Windows command line. Windows + /// argv rules read \" as an escaped quote, so a quoted token ending in '\' never closes + /// ("C:\repo\" parses as C:\repo") and either the path itself or every argument + /// after it is corrupted. A bare root ("C:\", "/") is all separator and is returned untouched; + /// so is null/blank. + /// + public static string? TrimTrailingSeparator(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return trimmed.Length == 0 || trimmed.EndsWith(':') ? path : trimmed; + } + /// ~/.todo-app — parent directory for db, logs, config, sandbox, worktrees. public static string AppDataRoot() => Expand("~/.todo-app"); diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index f925128b..b4ccc026 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -16,16 +16,25 @@ public sealed class ListRepository public async Task AddAsync(ListEntity entity, CancellationToken ct = default) { + NormalizeWorkingDir(entity); _context.Lists.Add(entity); await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) { + NormalizeWorkingDir(entity); _context.Lists.Update(entity); await _context.SaveChangesAsync(ct); } + // A working dir is user-supplied (typed, folder-picked, imported, or set over MCP) and every + // consumer eventually hands it to the claude CLI or wt.exe as one quoted command-line token — + // where a trailing '\' escapes its own closing quote. Normalizing here covers all writers; + // see Paths.TrimTrailingSeparator. + private static void NormalizeWorkingDir(ListEntity entity) => + entity.WorkingDir = Paths.TrimTrailingSeparator(entity.WorkingDir); + public async Task DeleteAsync(string listId, CancellationToken ct = default) { var taskIds = await _context.Tasks diff --git a/src/ClaudeDo.Installer/Core/ClaudeHelpLauncher.cs b/src/ClaudeDo.Installer/Core/ClaudeHelpLauncher.cs index e9bf576f..cd66e99c 100644 --- a/src/ClaudeDo.Installer/Core/ClaudeHelpLauncher.cs +++ b/src/ClaudeDo.Installer/Core/ClaudeHelpLauncher.cs @@ -178,5 +178,6 @@ public sealed class ClaudeHelpLauncher private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value; - private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/')); + private static string QuoteDirectory(string directory) => + Quote(ClaudeDo.Data.Paths.TrimTrailingSeparator(directory)!); } diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs index c04a3f5d..e0804e8a 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs @@ -143,15 +143,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable { var dir = row?.WorkingDir; if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return; + // Trailing separator + ArgumentList, not string interpolation: "C:\repo\" would parse as + // C:\repo" and wt would refuse it as a starting directory (Paths.TrimTrailingSeparator). + // Rows loaded before ListRepository normalized on write can still carry one. + dir = Paths.TrimTrailingSeparator(dir)!; ForegroundHelper.AllowAny(); try { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + var psi = new System.Diagnostics.ProcessStartInfo { FileName = "wt.exe", - Arguments = $"-d \"{dir}\"", UseShellExecute = true, - }); + }; + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(dir); + System.Diagnostics.Process.Start(psi); } catch { diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index d79eb512..7bf267f2 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -352,18 +352,12 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService return new LaunchSpec(repoDir, resolvedClaude, args, env); } - // Strips a trailing directory separator off a path bound for the CLI argument list. The ConPTY - // host flattens Args into ONE Windows command line and quotes each token, so a token ending in - // '\' escapes its own closing quote ("C:\repo\" parses as C:\repo" ...) and every following - // argument is absorbed into the preceding variadic flag -- for a list handler that means - // --add-dir swallows --append-system-prompt-file AND the positional kickoff, and the session - // opens with no prompt at all. Only user-supplied list working dirs can carry one; the session - // dirs we build never do. A bare root ("C:\", "/") is all separator and is left untouched. - private static string TrimTrailingSeparator(string dir) - { - var trimmed = dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - return trimmed.Length == 0 || trimmed.EndsWith(':') ? dir : trimmed; - } + // The ConPTY host flattens Args into ONE Windows command line and quotes each token, so a token + // ending in '\' escapes its own closing quote and every following argument is absorbed into the + // preceding variadic flag -- for a list handler that means --add-dir swallows + // --append-system-prompt-file AND the positional kickoff, and the session opens with no prompt + // at all. ListRepository normalizes on write now, but rows written before that still carry one. + private static string TrimTrailingSeparator(string dir) => Paths.TrimTrailingSeparator(dir)!; // Renders one task as a brief list item. A description can itself be arbitrary Markdown // (headings, lists, fenced code) — those must not merge into the brief's own task list, so diff --git a/tests/ClaudeDo.Data.Tests/WorkingDirNormalizationTests.cs b/tests/ClaudeDo.Data.Tests/WorkingDirNormalizationTests.cs new file mode 100644 index 00000000..4a2e90a5 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/WorkingDirNormalizationTests.cs @@ -0,0 +1,98 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Tests; + +// A list working dir stored with a trailing separator ("C:\repo\") breaks every consumer that +// puts it on a Windows command line: the token's closing quote is escaped by the backslash, so +// "C:\repo\" parses as C:\repo" and the arg (or every arg after it) is corrupted. Observed twice +// -- ConPTY list handlers (2026-08-06) and "Open in terminal" (2026-08-10). Normalizing on write +// is the only place that covers all writers (UI, repo import, hub, MCP). +public sealed class WorkingDirNormalizationTests : IDisposable +{ + private readonly string _dbPath; + private readonly ClaudeDoDbContext _ctx; + + public WorkingDirNormalizationTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_workdir_{Guid.NewGuid():N}.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + _ctx = new ClaudeDoDbContext(options); + _ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + _ctx.Dispose(); + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + [Theory] + [InlineData(@"C:\Dev\Tests\StaplerTracking\", @"C:\Dev\Tests\StaplerTracking")] + [InlineData(@"C:\Dev\Tests\StaplerTracking\\", @"C:\Dev\Tests\StaplerTracking")] + [InlineData("C:/Dev/Tests/StaplerTracking/", "C:/Dev/Tests/StaplerTracking")] + [InlineData(@"C:\Dev\Tests\StaplerTracking", @"C:\Dev\Tests\StaplerTracking")] + public void TrimTrailingSeparator_strips_trailing_separators(string input, string expected) => + Assert.Equal(expected, Paths.TrimTrailingSeparator(input)); + + // A bare drive root IS its trailing separator -- "C:" without it means "current dir on C:", + // a different location. Same for the unix root. + [Theory] + [InlineData(@"C:\")] + [InlineData("/")] + public void TrimTrailingSeparator_leaves_a_bare_root_alone(string root) => + Assert.Equal(root, Paths.TrimTrailingSeparator(root)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TrimTrailingSeparator_passes_blank_through(string? input) => + Assert.Equal(input, Paths.TrimTrailingSeparator(input)); + + [Fact] + public async Task AddAsync_normalizes_a_trailing_separator() + { + var repo = new ListRepository(_ctx); + await repo.AddAsync(new ListEntity + { + Id = "l1", + Name = "Stapler Tracking", + WorkingDir = @"C:\Dev\Tests\StaplerTracking\", + CreatedAt = DateTime.UtcNow, + }); + + var stored = await repo.GetByIdAsync("l1"); + Assert.Equal(@"C:\Dev\Tests\StaplerTracking", stored!.WorkingDir); + } + + [Fact] + public async Task UpdateAsync_normalizes_a_trailing_separator() + { + var repo = new ListRepository(_ctx); + await repo.AddAsync(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + + var entity = await repo.GetByIdAsync("l1"); + entity!.WorkingDir = @"C:\Dev\Repos\Bandel.Hub\"; + await repo.UpdateAsync(entity); + + var stored = await repo.GetByIdAsync("l1"); + Assert.Equal(@"C:\Dev\Repos\Bandel.Hub", stored!.WorkingDir); + } + + [Fact] + public async Task AddAsync_leaves_a_null_working_dir_null() + { + var repo = new ListRepository(_ctx); + await repo.AddAsync(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + + var stored = await repo.GetByIdAsync("l1"); + Assert.Null(stored!.WorkingDir); + } +}