Skip to content

Fix: resolve real project paths for paths containing _, - or . (#465) - #487

Merged
mufeedvh merged 9 commits into
winfunc:mainfrom
bash-1337:fix/465-project-path-underscore
Nov 6, 2026
Merged

mufeedvh merged 9 commits into
winfunc:mainfrom
bash-1337:fix/465-project-path-underscore

Conversation

@bash-1337

Copy link
Copy Markdown
Collaborator

Title: Fix: resolve real project paths for paths containing _, - or . (#465)

Problem

Claude Code names project folders under ~/.claude/projects by replacing every non-alphanumeric character with -, so /home/michal_mikes/my-app and /home/michal/mikes/my/app produce the same folder name. opcode reversed this with replace('-', "/") and handed the resulting, non-existent directory to the claude process, so every prompt failed with a generic "Failed to send prompt" (#465, also reported for hyphenated and dot-folder paths in the issue comments).

Three defects combined:

  1. cwd lookup gave up early. get_project_path_from_sessions read only the first 10 lines of each session file; newer session files start with entries without cwd, so opcode fell back to the lossy decoder.
  2. The fallback decoder was lossy, and it is the only source for projects that have no session files yet (new projects, or folders that only contain memory/).
  3. Inconsistent encoding and persisted bad paths. Encoders used replace('/', "-"), which does not match Claude Code for _ / .; wrong paths were also saved in localStorage and reused after restart.

Approach

  • Session scan (get_project_path_from_sessions): stream up to 200 lines per file and up to 5 files per project until a non-empty cwd is found; lines over 1 MiB and non-UTF-8 lines are skipped without buffering; a cheap "cwd" pre-filter avoids JSON-parsing irrelevant lines.
  • Filesystem-aware decoder: resolve the folder name by walking down from the root and keeping only child directories whose encoded name matches the remaining text (one directory listing per path level, no exponential search, deterministic on ambiguity, backtracks out of dead ends). Supports Unix names and Windows drive names (C--Users-...); UNC paths fall back to the previous best-effort guess.
  • encode_project_path helper mirroring Claude Code's naming, used by create_project and the two session-file lookups in agents.rs (via session_jsonl_path).
  • resolve_project_path: session cwd → filesystem match → guess, with a per-project cache invalidated by the folder's mtime. list_projects / get_project_sessions run their blocking filesystem work in spawn_blocking.
  • Stale saved paths: restored chat tabs re-resolve their project path from list_projects by project id and repair the saved record.
  • Clear error: the working directory is validated before spawning; the UI shows e.g. Failed to send prompt: Project directory not found: <path>.

Relation to #471

#471 also lifts the 10-line limit in get_project_path_from_sessions (thanks @chaochaoweb3). This PR covers that part with a bounded scan (so a session file without any cwd is never read to the end) and additionally fixes the cases #471 cannot reach: projects with no session files at all, the encoders, persisted bad paths, the error message and Windows names. Happy to rebase onto #471 if it is merged first.

Performance

Release-mode benchmark (included as an #[ignore]d test, run with cargo test --release -- --ignored --nocapture bench_):

Scenario Before After
200 projects, cwd on line 1 (cold) ~7–10 ms ~7–10 ms (unchanged)
Same, repeated listing (cached) ~7–10 ms ~0.4 ms
One project with a ~100 MB session file ~0.13 ms ~0.12 ms

Testing

  • cargo test: 32 tests pass (23 new): underscore / hyphen / dot-folder / mixed paths, cwd after many lines, bounded scan, oversized and invalid lines, ambiguity, backtracking, Windows layouts (in-memory filesystem), caching, encoder rules, session-file paths, directory validation. cargo fmt --check clean; no new clippy warnings beyond dead_code in the opcode-web binary (same as existing command helpers).
  • Differential check against main (same data, every field returned by list_projects, get_project_sessions, read_session_jsonl, create_project compared): all cases that worked before are identical; only the broken cases changed (e.g. …/new/project → …/new_project, …//hidden/dot/app → …/.hidden/dot-app).
  • Manual (macOS): a real project ~/Desktop/Office-Personal/Mob-Auto (folder contains only memory/, no session files) was listed as /Users/…/Office/Personal/Mob/Auto on main and failed to start; with this PR it resolves correctly and a prompt runs.

Documentation

All new functions have /// docs. No README change needed (no user-facing configuration changed). No new dependencies.

Limitations / side effects

  • Windows is covered by unit tests with an in-memory filesystem only; it was not run on a Windows machine.
  • Project folders created earlier by opcode itself with the old naming (e.g. -home-a_b) are left in place and still show a best-effort path; nothing is deleted.
  • A resolved path is cached until the project folder's mtime changes, so a project moved on disk is picked up when its next session file is created.
  • Live streaming in the open chat is affected by the separate, pre-existing Bug: Tauri events never reach frontend — require() fails in ESM/Vite context, falls back to DOM listeners #462 (events listener loaded via require()); not changed here.
  • src/lib/analytics/index.ts has an existing tsc error unrelated to this change.

Fixes #465

Newer Claude Code session files start with entries (last-prompt,
permission-mode) that carry no cwd. get_project_path_from_sessions only read
the first 10 lines of each file, so it often gave up and fell back to the
lossy folder-name decoder, producing paths like /home/michal/mikes/projects
and "Failed to send prompt" (winfunc#465).

- Stream up to 200 lines per file and try up to 5 files per project
- Skip lines over 1 MiB and non-UTF8 lines without buffering them
- Pre-filter on the "cwd" key to avoid JSON-parsing irrelevant lines
- Add regression tests and an ignored baseline benchmark
Claude Code names project folders by replacing every non-alphanumeric
character (including _, -, ., \ and :) with '-', per UTF-16 code unit. The
existing call sites only replaced '/', so they could not locate folders for
paths containing '_' or '.' (winfunc#465). Add a single shared, allocation-light
helper that mirrors the real rule; call sites are switched over in follow-up
commits.
decode_project_path turned every '-' into '/', so folder names for paths
containing '_', '-' or '.' decoded to non-existent directories such as
/home/michal/mikes/projects (winfunc#465).

Resolve the name by walking down from the root and keeping only child
directories whose encoded name matches the front of the remaining text.
This needs one directory listing per path level (no exponential search),
allocates nothing per entry, backtracks out of dead ends and picks
deterministically (name order) when layouts encode identically. The old
'-' -> '/' guess remains only as a last resort. The filesystem is behind a
small PathProbe trait so Windows-style layouts can be tested on any host.
Both commands fell back to the lossy '-' -> '/' decoder whenever the session
files had no cwd, handing a non-existent directory to the spawned claude
process ("Failed to send prompt", winfunc#465).

Add resolve_project_path: session cwd first, then the folder name resolved
against the real filesystem, then a best-effort guess. Results from the first
two are cached per project folder and invalidated by the folder's mtime, so
repeated listings skip all file reads (200 projects: ~8 ms cold, ~0.4 ms
cached). The blocking filesystem work now runs in spawn_blocking so it no
longer stalls the async runtime.
…puted

create_project, read_session_jsonl and the session file monitor encoded the
project path with replace('/', "-"), which leaves '_' and '.' untouched, so
they looked for (or created) folders that Claude Code never created for such
paths (winfunc#465). Use the shared encode_project_path helper, and add
session_jsonl_path so the two agents.rs lookups share one tested
implementation.

slash_commands.rs builds an internal command ID, not a project folder name,
so it is intentionally unchanged.
When the project directory does not exist, spawning claude failed with an
opaque OS error and the UI only showed "Failed to send prompt", which made
winfunc#465 hard to diagnose.

- Validate the working directory before spawning and return
  "Project directory not found: <path>" (or "Project path is empty")
- Show the backend's message in the chat error instead of discarding it
Chat tabs restored their project path from localStorage, so a path saved by
an older build (e.g. /home/michal/mikes/p instead of /home/michal_mikes/p)
kept breaking prompts across restarts (winfunc#465) until the user edited
localStorage by hand.

On restore, look the path up by project id in the backend's current project
list (one listing call, only when a chat tab needs restoring), use it, and
repair the saved record. Falls back to the saved path if the project is
unknown or the listing fails.
Claude Code names a Windows project folder by replacing every non-alphanumeric
character, so C:\Users\john_doe\app becomes C--Users-john-doe-app. The
resolver only understood Unix names rooted at '/', so on Windows the same
lossy '-' -> '/' guess broke prompts (winfunc#465).

- Recognise a leading '<drive>--' as the root '<drive>:\' and walk down with
  '\' separators; UNC paths are unsupported and fall back to the guess
- Make PathProbe string-based so Windows layouts are unit-tested on any host
- Keep the last-resort guess platform-shaped (C:\a\b instead of C://a/b)

The encoder already maps '\' and ':' to '-', so no change was needed there.
Name the project path cache type and use iter::repeat_n in a test. No
behaviour change.
@mufeedvh
mufeedvh merged commit e8a03e0 into winfunc:main Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Project path incorrectly reconstructed when username contains underscores — causes "Failed to send prompt"

3 participants