backnotprop / backnotprop/plannotator
/plannotator-last serves another session's message when cwd diverges from the transcript's project slug
- Dominant language
- TypeScript
- Stars
- 8.7k
- Forks
- 649
- Avg merge
- 11h 12m
- Merged PRs (30d)
- 109
Description
# `/plannotator-last` serves another session's message when cwd diverges from the transcript's project slug
**Plugin:** plannotator 0.21.2 (Claude Code plugin)
**Host:** Claude Code 2.1.x, macOS
## Summary
`plannotator last` / `annotate-last` resolves "the last assistant message" from
the **current working directory**, not from the invoking session's identity. When
a session's cwd no longer maps to the project slug where its transcript is stored,
and another Claude Code session is live under a shared ancestor directory, the
ancestor-directory-walk fallback picks up the *other* session's transcript and
serves its most recent message. The annotation/review then applies to a message
the user never wrote in this session.
## Impact
- The message rendered for annotation is from the wrong session. If the user
doesn't catch the mismatch, any annotation or review they write attaches to a
different session's content.
- In practice the swapped-in message is visibly not what the user was working on,
so they close the Plannotator session without annotating. The intended review
never happens and the workflow is derailed — the failure is loud, but it still
blocks the review.
- Triggers in a very normal setup: git worktrees plus more than one concurrent
Claude Code session in the same repo tree.
## Reproduction
1. Launch a Claude Code session in worktree A (`…/repo/.worktrees/feat-a`). Its
transcript is written to `~/.claude/projects/{slug-A}/{sessionId}.jsonl`.
2. Mid-session, switch the session's cwd into a different worktree B
(`…/repo/.worktrees/feat-b`) — e.g. via an `EnterWorktree`-style cwd change.
The transcript stays under `{slug-A}`; the `{slug-B}` project dir is empty (no
`.jsonl`).
3. Have a second Claude Code session live in the base checkout (`…/repo`), session
X, actively writing `~/.claude/projects/{slug-base}/{X}.jsonl`.
4. From session A (now cwd = worktree B), run `/plannotator-last`.
**Expected:** the last assistant message from session A.
**Actual:** the last assistant message from session X (the base-checkout session),
because its transcript is the most-recently-modified one found by walking up from
worktree B's cwd to the shared base directory.
## Root cause
In `apps/hook/server/index.ts` (`annotate-last` / `last` branch) the Claude path
resolves a transcript via a precedence ladder, all keyed off
`projectRoot = PLANNOTATOR_CWD || process.cwd()`:
1. **Ancestor-PID metadata** — walk the process tree, read
`~/.claude/sessions/.json` at each hop. Fails here: `/plannotator-last`
runs as a detached background shell, so its process tree no longer chains to
the Claude Code process, and no `.json` is found.
2. **Cwd-scan of session metadata** — read `~/.claude/sessions/*.json`, filter by
`cwd === projectRoot`, pick newest `startedAt`. Even when this resolves the
correct `sessionId`, the subsequent transcript lookup is derived from the cwd
slug (`findSessionLogsForCwd` in `session-log.ts`), which points at the empty
`{slug-B}` dir — so it yields no message and falls through.
3. **Cwd slug match (mtime)** — `findSessionLogsForCwd(projectRoot)` →
`~/.claude/projects/{slug-B}/*.jsonl`. Empty. Its only fallback is a
case-insensitive directory match (for Windows drive-letter casing), which does
not bridge to `{slug-A}`. Miss.
4. **Ancestor directory walk** — climbs `projectRoot`'s parents. From worktree B it
reaches the base repo dir, whose `{slug-base}` holds session X's transcript.
That transcript is the most-recently-modified, so its last message is returned.
The two structural problems:
- **Transcript lookup is cwd-slug-based, not sessionId-based.** Step 2 can resolve
the right `sessionId` from metadata but then can't find its transcript because
the file lives under the slug of the *launch* cwd, not the *current* cwd. A
session whose cwd has moved (worktrees, `cd`) becomes unfindable by its own id.
- **The ancestor-directory-walk fallback crosses into directories owned by other
live sessions.** Under a shared monorepo/worktree root, climbing parents lands
on a slug that belongs to a different, concurrently-active session, and
"newest `.jsonl` wins" hands back that session's message.
The code comments already flag step 3 as "Fragile when multiple sessions exist for
the same project" — this is that fragility, made worse by worktree cwd divergence.
## Suggested fix
1. **Resolve the transcript by `sessionId`, not by cwd slug.** Once step 1/2 yields
a `sessionId` (or once `.json` gives one), locate `{sessionId}.jsonl` by
searching across `~/.claude/projects/*/` (or store/read the transcript path in
the session metadata directly). This makes resolution robust to a session whose
cwd moved after launch.
2. **Don't let the ancestor-directory-walk cross into another live session's
directory.** Gate the walk so it never selects a transcript belonging to a
`sessions/*.json` entry whose `status` is active/`busy` and whose `sessionId`
differs from the resolving session. Better: if any precise selector identified a
`sessionId`, never fall through to mtime/ancestor heuristics at all — fail
closed with "couldn't find this session's transcript" instead of serving a
sibling's.
3. **Make the background-shell PID walk survivable**, or pass the host session id
into the command explicitly (e.g. a `--session-id` arg / env var the slash
command populates) so step 1 doesn't depend on an intact process tree.
## Evidence from a live occurrence
- Invoking session's metadata correctly recorded its moved cwd
(`sessions/.json`: `cwd` = worktree B, `sessionId` = A), yet its transcript
`A.jsonl` was under `{slug-A}` and `{slug-B}` contained no `.jsonl`.
- The served message came verbatim from a different session's transcript
(`X.jsonl`) under `{slug-base}`, which was the most-recently-modified jsonl
reachable by walking worktree B's cwd up to the base repo dir.
Contributor guide
Research direction
Start in apps/hook/server/index.ts, tracing the Claude annotate-last/last precedence ladder, then read findSessionLogsForCwd in session-log.ts and the session metadata handling. Verify behavior with the worktree-A/worktree-B and shared-base reproduction: the command should resolve session A's transcript by sessionId or fail closed, never return session X's message.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- cli, developer-experience
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100