Commit Graph

10 Commits

Author SHA1 Message Date
BrandonNoad 8d8e643976 fix(hook): read annotate-last from Claude Code's transcript tree, not file order (#1141)
* fix(hook): read annotate-last from the transcript tree, not file order

Claude Code session logs are append-only and tree-shaped: every entry
records the entry it follows in `parentUuid`. `/rewind` writes nothing at
all. The next committed message simply re-parents to an earlier entry,
leaving everything after it orphaned in the file forever.

`extractRecentRenderedMessages` scanned bottom-up in file order, so those
orphans were still offered in the annotate-last message picker even though
they are no longer part of the conversation. On a rewound session in this
repo the picker listed 10 messages where only 7 are live.

Add `resolveActiveBranchIndices`, which walks `parentUuid` from the newest
id-bearing entry back to the root. It returns indices rather than a
filtered array so callers keep reporting real file line numbers, and
returns null on a chain it cannot trust (no ids, dangling parent, cycle) so
callers degrade to the previous file-order read instead of returning
nothing. Note the newest entry is not always the last line: `last-prompt`,
`ai-title`, `mode` and `file-history-snapshot` carry no ids and are often
written last.

Opt in at the Claude Code call site only. Droid's call site, Codex and
Copilot (separate parsers), and Pi/OpenCode/Amp (live APIs, no transcript
reads) are all unaffected. Both new parameters default to off.

Checked against 311 local transcripts: every one walks cleanly to the root
with no dangling parents or cycles, and the default pick is byte-identical
under both readings in all 298 that contain a message. That is expected,
since a committed rewind's new branch is always the newest lines in the
file. The picker is where the difference shows up.

Test fixtures previously assigned random `parentUuid`s, which left every
entry an orphan and made branch resolution untestable, so `buildLog` now
links them into a real chain and `buildRewoundLog` models a fork.

* fix(hook): fail open when the active branch has no assistant messages

A /compact boundary is written with parentUuid: null, so it is a tree
root: the active-branch walk stops there and a freshly-compacted session
yields zero messages. Callers treat an empty result as "wrong log file"
and walk off to an older session, so fail open to the file-order read
instead. Adds tests for the compaction cut, the fallback, and the
post-compaction recovery.

Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-29 22:59:43 -07:00
Graham Lipsman be2c81fa3b annotate-last: pick which message to annotate (fixes #800) (#809)
* feat: message picker for annotate-last (#800)

When running /plannotator-last after /rewind, the newest transcript
entry is no longer the message the user intended to annotate, and there
was no affordance to pick a different one.

Adds a picker UI that surfaces the recent assistant messages so the
user can choose which one to annotate:

- A "Message N of M" button in the Viewer's sticky-top action bar
  (alongside Copy / Global comment / Attachments), so it stays
  accessible while scrolling.
- A "Messages" tab in the left sidebar with the full list
  (newest-first, preview + timestamp, default ★), mirroring the
  existing Files / Versions / Archive tab pattern.

Wired for Claude Code, Codex, and Droid (all share apps/hook/server).
OpenCode, Pi, and Copilot still get the original single-message
behavior — they don't emit recentMessages, so the picker affordances
hide cleanly.

Default selection (index 0) matches today's "last message" behavior,
so users who don't interact with the picker see no change.

* feat: extend annotate-last picker to Copilot and OpenCode

The picker UI from #800 was wired for Claude / Codex / Droid only. Pull
Copilot and OpenCode onto the same shape so users on those harnesses
also get the recent-messages picker when annotating the last assistant
message.

- Copilot: replace getLastCopilotMessage with getRecentCopilotMessages,
  walking events.jsonl newest-first up to 25 assistant.message events.
- OpenCode: rewrite the session walk to collect up to 25 messages
  (newest first) instead of bailing on the first hit; normalize the SDK
  time.created (ms epoch) to ISO to match the shared picker contract.
- Both pass recentMessages to startAnnotateServer only when length > 1,
  matching the existing Claude/Codex/Droid behavior.

Also trims a leftover narrating comment in MessagesBrowser and refreshes
the stale Copilot session-parser header.

Pi parity follows in the next commit (needs round-trip of the picker
selection through /api/feedback so its post-submit anchoring quotes the
right message).

* feat(pi): wire annotate-last picker with feedback round-trip

Extends the picker UI (#800) to Pi and fixes a Pi-specific anchoring bug
the picker would otherwise introduce.

Picker plumbing
- assistant-message: getRecentAssistantMessages walks the active branch
  newest-first, returning { messageId, text, timestamp? } in the same
  shape the other harnesses produce.
- Plumbed through plannotator-browser / plannotator-events so the Bun
  server's recentMessages option is populated when the branch has more
  than one assistant message.

Anchoring fix
- Pi quotes the targeted assistant message back to the agent because its
  UX is async — the conversation may have moved on by feedback time.
  With the picker, that target is no longer guaranteed to be the
  snapshot taken when the UI opened. The editor now sends the user's
  selectedMessageId with /api/feedback; Pi looks it up in the current
  branch via findAssistantMessageByEntryId and quotes that message
  instead. Falls back to the original snapshot if the entry is gone.
- The round-trip field is optional and only meaningful in annotate-last
  mode; other harnesses (and other modes) ignore it.

Timestamp safety
- Pi's SDK currently types SessionEntryBase.timestamp as string, but the
  picker contract everywhere else is ISO. Treat the value as unknown and
  normalize string/number(ms)/Date to ISO; drop anything else, rather
  than blind-casting and risking silent drift if the SDK changes.

* chore: strip issue-number references from comments

Comments shouldn't rely on external references — issue numbers age out
of context, link rot is a thing, and a reader shouldn't need to open
GitHub to understand why a line exists. Strip the `(#800)` and `(#570)`
parentheticals from comments and doc strings across the picker and
review-gate code; the surrounding "why" content is preserved.

* fix: prevent removeChild crash when switching annotate-last messages

Switching the picked message remounted nothing, so React reconciled new
content against DOM that web-highlighter had mutated with <mark> nodes,
throwing removeChild. Drive the Viewer key (and StickyHeaderLane's
remount token) off a shared viewerContentKey so a message switch fully
remounts the Viewer and re-anchors the sticky-header observer.

Also cap MessagesBrowser row previews via previewText() and drop the
redundant 'block' class that was overriding line-clamp-2.

* feat: persist annotate-last feedback across messages

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-06-03 13:01:36 -07:00
Michael Ramos 4de62e83e1 Add Droid slash-command integration (#787)
* feat: add Droid slash-command integration

* fix: restore Droid command launcher behavior

* chore: remove archive command from homepage

* fix(droid): tighten last-message session resolution
2026-05-24 09:29:13 -07:00
Michael Ramos 5438f66456 fix: honor CLAUDE_CONFIG_DIR in session log discovery (#786)
Resolve DEFAULT_SESSIONS_DIR and DEFAULT_PROJECTS_DIR from
CLAUDE_CONFIG_DIR when set, falling back to ~/.claude.
Add projectsDirOverride param to findSessionLogsByAncestorWalk
so all four resolution tiers respect custom config paths.

Closes #783
2026-05-23 19:19:06 -07:00
Michael Ramos e0fb690039 fix(session-log): detect ghost sessions from /clear to resolve correct log (#661)
* fix(session-log): detect ghost sessions from /clear to resolve correct log

After /clear, Claude Code creates a new session (new .jsonl file) but
never updates ~/.claude/sessions/<pid>.json — the metadata retains the
old sessionId. The ancestor-PID resolver would confidently return the
stale log, preventing fallthrough to mtime-based tiers.

Fix: after tier-1 matches a log that isn't the newest by mtime, check
whether the newer file's sessionId is registered in any metadata file.
If not, it's a "ghost" session from /clear — prefer it. If it IS
registered, it belongs to a concurrent session — keep the PID result.

Fixes #643

* test: fix flaky mtime ordering in ghost detection tests

Explicitly backdate the "older" file by 5 seconds instead of relying on
write order, which is non-deterministic when both files are created
within the same millisecond.
2026-05-04 20:35:08 -07:00
Eli Thompson 24a070d1cd fix(session-log): walk ancestor PIDs to resolve correct session log (#598)
* fix(session-log): walk ancestor PIDs to find Claude Code session metadata

When plannotator is invoked from a slash command's `!` bang, the direct
parent process is an intermediate bash shell spawned by the Bash tool —
not Claude Code itself. The old resolveSessionLogByPpid() only checked
process.ppid, so it always missed the session metadata file and fell back
to mtime-based selection, which picks the wrong log when multiple sessions
exist for the same project.

New resolution ladder (four tiers):
1. Ancestor-PID walk: call `ps -o ppid=` repeatedly from process.ppid up
   to 8 hops, checking ~/.claude/sessions/<pid>.json at each hop.
   Deterministic — no guessing, matches exact session every time.
2. Cwd-scan: read every ~/.claude/sessions/*.json, filter by cwd, pick
   most recent startedAt. Handles cases where ps is unavailable.
3. CWD slug mtime (legacy): existing behavior, fragile with multiple
   sessions.
4. Ancestor directory walk: handles cd-deeper-into-subdirectory cases.

Adds getAncestorPids (injectable getParent for testing), resolveSessionLogByAncestorPids,
and resolveSessionLogByCwdScan. Exports SessionMetadata and accepts
projectsDirOverride on findSessionLogsForCwd for test isolation.

17 new tests cover: edge cases for getAncestorPids (cycles, maxHops,
self-loops), resolveSessionLogByAncestorPids (finds correct session among
multiple, skips missing logs, falls back when no metadata matches), and
resolveSessionLogByCwdScan (picks newest startedAt, ignores mismatched
cwd, handles missing sessions dir).

Closes #458

* refactor(session-log): simplify resolver and findSessionLogs for clarity

* fix(session-log): add Windows support to ancestor-PID resolver

Tier 1 used `ps`, which doesn't exist on Windows. Multiple Claude Code
sessions in the same repo remained broken on Windows because tier 2
(cwd scan) can't disambiguate when both sessions share the same cwd.

- Replace per-hop `ps` calls with a single process-table snapshot (`ps`
  on Unix, PowerShell Get-CimInstance on Windows), cached across the
  walk. One spawn instead of up to eight; faster on both platforms.
- Export pure-function parsers (parseProcessTablePs,
  parseProcessTableCsv) so the Windows path is unit-testable without
  a Windows runner.
- Normalize cwd comparison in resolveSessionLogByCwdScan: Windows is
  case-insensitive and processes may report drive letters in either
  case, so fold slashes and lowercase before comparing.

Windows viability confirmed empirically: ~/.claude/sessions/<pid>.json
exists with the expected schema on Windows 11 + Claude Code 2.1.116.

For provenance purposes, this commit was AI assisted.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-04-21 16:28:38 -07:00
Alexander Tarnavsky Eitan adca453380 Fix project slug derivation to match Claude Code's actual algorithm (#401)
Claude Code replaces all non-alphanumeric characters (except `-`) with `-`
when deriving the project slug from the working directory path. The previous
implementation only replaced `/`, causing `annotate-last` to fail for paths
containing dots (e.g. `.worktrees`) or underscores (e.g. `feat_branch`).

The computed slug wouldn't match the actual session log directory, resulting
in "No rendered assistant message found" errors.

Co-authored-by: alex <alexander.eitan@contiamo.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-03-26 13:03:35 -07:00
Yassir Janah d3cb2b945d fix: annotate-last resolves wrong session after cd (#366)
* fix: annotate-last resolves wrong session after cd

When the user cd's during a Claude Code session, /plannotator-last
picks up a stale session log from a different project directory because
findSessionLogsForCwd() uses the current shell CWD, not the session's
original project directory.

Add three-tier session resolution:
1. PPID metadata (~/.claude/sessions/<ppid>.json) for deterministic O(1) lookup
2. CWD slug match (existing behavior, unchanged)
3. Ancestor directory walk as fallback

Also: getLastRenderedMessage() now catches read errors instead of
throwing, and extracted tryLogCandidates() to deduplicate the
candidate iteration loop.

* fix: use case-insensitive slug lookup in PPID resolver

resolveSessionLogByPpid() was constructing the log path directly,
bypassing the case-insensitive directory scan that findSessionLogsForCwd()
already provides for Windows. Reuse that function instead.

For provenance purposes, this commit was AI assisted.

---------

Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-03-22 10:04:38 -07:00
Michael Ramos 5e940db2e3 fix: align projectSlugFromCwd with Claude Code's path normalization (#340)
Claude Code replaces all non-[a-zA-Z0-9-] characters with dashes when
deriving project slugs, but we only replaced forward slashes. This broke
annotate-last on Windows and with non-ASCII (e.g. Cyrillic) paths.

Also adds a case-insensitive fallback for directory lookup to handle
Windows drive letter casing differences (C: vs c:).

Closes #339

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 10:22:11 -07:00
Michael Ramos 6b775ea1ed feat: /plannotator-last — annotate the last agent message (#325)
* feat: add /plannotator-last command to annotate last assistant message

Adds a new slash command that extracts the last rendered assistant message
from Claude Code's session log and opens it in the annotation UI.

Session log parser (apps/hook/server/session-log.ts):
- Parses Claude Code JSONL logs at ~/.claude/projects/{slug}/*.jsonl
- Finds the last assistant message.id with text content blocks
- Skips noise entries (progress, system, file-history-snapshot, queue-operation)
- Filters system-generated user messages by prefix to avoid false turn boundaries
- Walks backward through empty turns when back-to-back user messages exist
- No anchoring — reads from end of log since <command-message> isn't written
  until after the binary completes

New files:
- apps/hook/commands/plannotator-last.md — slash command definition
- apps/hook/server/session-log.ts — Claude-Code-specific log parser
- apps/hook/server/session-log.test.ts — 30 tests covering streaming chunks,
  tool call turns, sub-agent noise, stop hooks, thinking blocks, and edge cases

Modified:
- apps/hook/server/index.ts — annotate-last subcommand

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove 3 redundant real-world scenario tests

These duplicated coverage already provided by focused unit tests:
- "full conversation" → covered by "grabs last message.id in multi-tool turn"
- "stop hook interrupted" → covered by "skips progress and system noise"
- "long tool-only sequence" → covered by "skips tool-only assistant entries"

Kept the thinking block test (unique coverage). 27 tests remain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /plannotator-last command to Pi extension

Uses Pi's session manager API to find the last assistant message —
walks backward through ctx.sessionManager.getEntries(), finds the
last entry with role "assistant" and text content, opens it in the
annotation UI. Reuses existing isAssistantMessage(), getTextContent(),
startAnnotateServer(), and runBrowserReview() from the extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add /plannotator-last to OpenCode plugin + extract command handlers

Adds annotate-last command that fetches session messages via
client.session.messages(), finds the last assistant message with text
parts, and opens it in the annotation UI.

Refactors command handling: extracts review, annotate, and annotate-last
handlers from the inline event hook into commands.ts module. Reduces
index.ts by ~120 lines and makes adding future commands cleaner.

New files:
- apps/opencode-plugin/commands.ts — extracted command handlers
- apps/opencode-plugin/commands/plannotator-last.md — command metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: context-aware UI labels for annotate-last mode

Adds "annotate-last" mode to the annotate server, passed through to the
UI via /api/plan response. The editor uses this to show "Copy message"
instead of "Copy plan", and "annotations on the message" in the
completion overlay.

- packages/server/annotate.ts: new `mode` option on AnnotateServerOptions
- packages/editor/App.tsx: annotateSource state derived from mode
- packages/ui/components/Viewer.tsx: copyLabel prop for button text
- All three harnesses pass mode: "annotate-last" in their callers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Codex support to annotate-last command

Detects Codex via CODEX_THREAD_ID env var (injected by Codex into every
spawned process). Uses the thread ID to find the rollout file in
~/.codex/sessions/, parses the Codex rollout JSONL format to extract
the last assistant message.

Also adds `plannotator last` alias for shorter usage in Codex bang
commands (!plannotator last).

New files:
- apps/hook/server/codex-session.ts — Codex rollout parser
- apps/hook/server/codex-session.test.ts — 9 tests

Modified:
- apps/hook/server/index.ts — Codex detection + `last` alias

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: context-aware feedback title + top spacing for paragraph-first content

- exportAnnotations now accepts a title param: "Message Feedback" for
  annotate-last, "File Feedback" for file annotation, "Plan Feedback"
  for plan review (default)
- Adds top spacer when content starts with a paragraph (not a heading)
  and has no frontmatter, fixing tight spacing in annotate-last mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add sandbox scripts for Pi and Codex testing

- sandbox-pi.sh: builds extension, creates temp project, installs via
  `pi install`, launches Pi with sample files
- sandbox-codex.sh: compiles binary, creates temp project, launches
  Codex. Test with `!plannotator last`

Both follow the same pattern as sandbox-opencode.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add hook build step to opencode sandbox script

The opencode build copies HTML from hook/dist/ — without building hook
first, the sandbox could use stale HTML. Pi and Codex sandboxes already
had this step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove command body from plannotator-last to prevent agent response

The .md body was being sent to the agent as a prompt, causing it to
respond with "Opening annotation UI..." before the event handler could
fetch messages. That response became the "last message" instead of the
actual one. Empty body = agent stays silent, event handler intercepts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use command.execute.before hook for OpenCode annotate-last

Moves plannotator-last from the passive event hook to the
command.execute.before hook. This intercepts the command before the
agent sees it, clears output.parts so the agent stays silent, fetches
session messages, opens the annotation UI, then sends feedback via
client.session.prompt() — same pattern as review/annotate.

Previously the agent would respond to the command body before the
event handler could fetch messages, polluting the session history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add Codex to origin type and agent name mapping

Origin "codex" was falling through to the default "Coding Agent" label.
Added "codex" to the origin union type across annotate server, editor,
and removed the `as any` cast in the hook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remote share link, plan-specific prose, and codex type unions

- Add writeRemoteShareLink to annotate-last onReady callback so remote
  sessions get a reachable URL
- Add subject parameter to exportAnnotations so feedback says "message"
  or "file" instead of "plan" when appropriate
- Add 'codex' to origin type unions in useAgents, Settings, UpdateBanner,
  and App.tsx fetch handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct JSDoc for projectSlugFromCwd (leading dash is kept, not stripped)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use RenderedMessage type instead of inline structural type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:49:34 -07:00