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);
+ }
+}