backnotprop / backnotprop/plannotator

/plannotator-last serves another session's message when cwd diverges from the transcript's project slug

Open
#985 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.