fix(daemon): escape underscores in Claude CWD path encoding#38
Merged
Conversation
claudeSessionFileExists silently returned false for every macOS user
whose home dir contains an underscore, so claudeResumeTemplate's
on-disk probe never accepted the recorded session id and the resume
template fell through to --continue. Result: after a daemon restart,
each claude-code pane opened Claude's most-recent-by-CWD session
instead of the snapshotted one, forcing manual recovery.
Cause: escapeClaudeCWD mapped /, \, and : to '-' but left '_' alone.
Claude Code also maps '_' to '-', so /Users/Foo_Bar lives under
~/.claude/projects/-Users-Foo-Bar/ while Quil was probing
~/.claude/projects/-Users-Foo_Bar/.
Fix: extend the strings.NewReplacer to also handle '_'. Confirmed
against real on-disk directories. Three regression rows added to
TestEscapeClaudeCWD plus three negative rows pinning the deliberately
conservative scope ('.', ' ', uppercase preserved) — if Claude is
later observed encoding any of those, extend the replacer and update
the assertions in the same diff so the shift is reviewable.
Also: Daemon.Stop() now calls refreshPluginStateFromHooks() before the
final snapshot, copying the live SessionStart-hook-recorded session id
into PluginState["session_id"] for every claude-code and opencode
pane. Without this, workspace.json carries the original preassigned
id forever and is stale after any /clear, /resume, or compaction; if
the hook file is later lost (sessions dir wiped, plugin uninstalled)
the restore probe falls back to --continue.
Tests cover the happy path (claude + opencode + terminal-pane-skip +
lazy nil-PluginState allocation), the empty/error hook read preserves
existing values (table-driven across both branches), and three
underscore-bearing path encodings plus three negative cases.
Concurrency: refreshPluginStateFromHooks runs after server stop and
collectorWG.Wait() in Stop(), so no goroutine can mutate panes; the
per-pane PluginMu.Lock() is kept for race-safety against any future
call site. Race detector passes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two fixes for the silent Claude Code session-restore regression diagnosed from the production daemon log: after a daemon restart, every claude-code pane respawned with `--continue` instead of `--resume <session_id>` even though the snapshotted ids were valid.
Diagnosis (from `
/.quil/quild.log` + `/.claude/projects/`)The respawn args ended with `--continue`. `claudeResumeTemplate` only emits `--continue` when both probe branches fail: hook-recorded id AND preassigned id. Both probes route through `claudeSessionFileExists` → `escapeClaudeCWD(cwd)` → `os.Stat(...).jsonl`. The probe path was being built as `
/.claude/projects/-Users-Foo_Bar/...` while Claude actually writes to `/.claude/projects/-Users-Foo-Bar/...` — underscore vs dash. Every macOS user whose home dir contains an underscore was silently hit; Windows users weren't because their test paths had no underscores.Changes
`escapeClaudeCWD` — the bug
Extended the `strings.NewReplacer` to also map `_` → `-`, matching Claude Code's per-project directory naming under `~/.claude/projects/`. Confirmed empirically against the real directories on macOS (Jun 2026).
Three regression rows added to `TestEscapeClaudeCWD`:
Three negative rows pin the conservative scope (`.`, space, uppercase preserved) so if Claude is later observed encoding any of those, the replacer and these assertions get extended together — the assumption shift is reviewable as one diff.
`Daemon.refreshPluginStateFromHooks()` — defense-in-depth
Per the user's F1 → Stop daemon idea: `Stop()` now calls a new helper before the final snapshot to copy each pane's live SessionStart-hook-recorded session id into `PluginState["session_id"]`. Without this, `workspace.json` always carries the original preassigned id and is stale after any `/clear`, `/resume`, or compaction. If the hook file is later lost (sessions dir wiped, plugin uninstalled, manual cleanup) the restore probe has nothing to fall back to.
Documentation
Test plan
Test coverage added
Considered and dropped