Skip to content

fix: fall back to hook-generated reactions when HTML comments are visible (#124) - #127

Open
TorranceTech wants to merge 1 commit into
ramarivera:mainfrom
TorranceTech:fix/buddy-comment-visible
Open

fix: fall back to hook-generated reactions when HTML comments are visible (#124)#127
TorranceTech wants to merge 1 commit into
ramarivera:mainfrom
TorranceTech:fix/buddy-comment-visible

Conversation

@TorranceTech

Copy link
Copy Markdown

Problem

Fixes #124.

Claude Code v2.1.169+ changed how markdown is rendered: <!-- ... --> HTML comments that were previously invisible now appear visibly in the chat output. This broke the end-of-turn reaction system — every response ends with a raw <!-- buddy: ... --> comment polluting the conversation.

Solution

Two-path strategy in hooks/buddy-comment.sh:

Primary path (unchanged, backward-compatible): if Claude writes <!-- buddy: ... --> and the comment is hidden, the hook extracts and uses it as before.

Fallback path (new): when no comment is found in the message, the hook calls server/turn-reaction.ts — a new lightweight script that reads the companion state and picks a "turn" reaction from the existing pool. The status line still updates on every turn without any HTML leaking into the chat.

The prompts in server/index.ts are updated to tell Claude to omit the comment when it would appear visible, since the hook now covers that case automatically. Old installations (where comments are still hidden) continue to work unchanged.

Changes

  • server/turn-reaction.ts — new script: reads companion state, calls getReaction("turn", ...), prints to stdout
  • hooks/buddy-comment.sh — adds fallback branch; updates header comment
  • server/index.ts — softens the HTML comment instruction in both getInstructions() and buddy://prompt

Test plan

  • All 246 existing tests pass (bun test)
  • turn-reaction.ts uses the same getReaction() path already covered by reactions.test.ts
  • Manually: start a session on CC v2.1.169+ — buddy status line updates each turn with no visible HTML in output
  • Manually: start a session on older CC — existing <!-- buddy: --> extraction still works

🤖 Generated with Claude Code

…ible

Claude Code v2.1.169+ renders <!-- buddy: --> HTML comments visibly in the
chat output instead of hiding them, breaking the end-of-turn reaction system
(issue ramarivera#124).

This fix adds a two-path strategy to buddy-comment.sh:
- Primary: extract the existing <!-- buddy: ... --> comment (works on older CC)
- Fallback: call server/turn-reaction.ts to generate a reaction from the turn
  pool when no comment is found, so the status line always updates cleanly

The prompt in server/index.ts is softened to tell Claude to omit the comment
if it would appear visible, since the hook now covers that case automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: TorranceTech <billy.anderson.it@gmail.com>
@ramarivera

Copy link
Copy Markdown
Owner

Hey @TorranceTech — CI is now approved to run on this PR, and it surfaced one typecheck failure:

server/turn-reaction.ts(27,5): error TS2352: Conversion of type 'BuddyStats' to type 'Record<string, number>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

If the cast is intentional, as unknown as Record<string, number> will satisfy the compiler — though a small helper that explicitly picks the numeric fields off BuddyStats would be the cleaner fix. Once typecheck is green this is good to review; the fallback behavior itself looks right.

🤖 Created with the help of AI (Claude Fable 5).

ramarivera added a commit that referenced this pull request Jul 26, 2026
#159)

* fix(server+hooks+README): make buddy_react the primary end-of-turn channel

The HTML-comment channel broke visibly in Claude Code v2.1.169+ (issue
end-of-turn, steering the model to the broken channel while keeping
the working MCP tool unused. Result: every assistant message leaked
the raw <!-- buddy: ... --> line and the primary model-authored path
never fired.

Accept-with-changes verdict on PR #127: that PR keeps the HTML comment
as primary and adds a canned-pool fallback for when the comment is
missing. That trades a visible-artifact bug for the same canned-pool
loss the user already complained about ('suspicious English placeholder
shit'). The real fix is the in-process option: make buddy_react the
primary channel (it renders nowhere in the transcript and is
model-authored by construction), keep the HTML-comment extractor as a
backward-compat fallback for older Claude Code versions, and only fall
through to a canned-pool line as a last resort.

Changes:
  * server/state.ts        — ReactionSource = tool|comment|fallback|none;
                             ReactionState.source is optional and loaded
                             as 'none' for legacy files. saveReaction()
                             takes an optional source argument (default
                             'tool' since every MCP-side caller is one).
  * server/index.ts        — getInstructions() and buddy://prompt now
                             direct the model to call buddy_react at the
                             end of every turn, with explicit examples.
                             The contradictory 'Do NOT use buddy_react
                             for end-of-turn comments' line is gone, as
                             is the 'do NOT append an invisible HTML
                             comment' steer. buddy_react / buddy_pet /
                             buddy_unmute tag their writes source=tool
                             or source=fallback. buddy_stats now loads
                             the reaction file and shows the last line
                             + its source so users can prove the bubble
                             is not canned.
  * hooks/buddy-comment.sh — header rewritten to describe the 3-source
                             chain; bookkeeping (turn counter, XP,
                             memory) always runs on assistant message;
                             reaction write skipped when a fresh source=
                             tool reaction is on disk; otherwise tries
                             the legacy HTML comment (source=comment)
                             then falls back to a pool pick via the new
                             server/turn-reaction.ts script (source=
                             fallback). Cooldown still rate-limits the
                             reaction write, not the bookkeeping.
  * server/turn-reaction.ts — new canned-pool entry used only as the
                             last-resort fallback. Mirrors what PR
                             #127 added but with the source tag wired
                             through.
  * README.md              — features card and 'How It Works' updated
                             to describe the new chain and provenance
                             tag; the '<!-- buddy: ... -->' Stop Hook
                             line is replaced with the actual chain
                             (skip tool → comment → fallback).

Tests: bun test (246 pass, 0 fail). bun run typecheck clean. Smoke
tests confirmed all three reaction paths (comment writes source=
comment, canned fallback writes source=fallback, fresh tool reaction
is left alone).

Refs #124, #154.

* fix(server+SKILL): reactions are statusline-only (F6 — maintainer decision)

The previous round argued for 'visible by design' based on the
hypothesis that MCP tool results render in Claude Code's transcript.
The maintainer observed from a live session with a screenshot: that
is false in his environment. Tool results are NOT visible; the
model was duplicating the reaction into the reply body, producing a
duplicate-on-every-turn stream the user does not want.

Correct model of the channel:
  * buddy_react is a real MCP tool call → its result is the bubble
    text on the statusline only.
  * The user never reads the reply text containing the tool call.
  * The instruction 'display the tool result verbatim' was the leak:
    it told the model to echo the reaction in prose, where Claude
    Code did not show it as a tool result and the user instead saw
    it duplicated into the reply.

Scrubbed:
  * server/index.ts:101  (NAME REACTIONS block)
  * server/index.ts:120  (END-OF-TURN block)
  * server/index.ts:300  (buddy_react tool description)
  * server/index.ts:1360 (buddy://prompt duplicate block — deleted
    entirely; corrected the keeper at the fence close above)
  * skills/buddy/SKILL.md:89 (the only writer of the duplicate)

Preserved:
  * skills/buddy/SKILL.md:95  (buddy_uninstall — different tool, the
    maintainer explicitly excluded that line).
  * The statusline-only framing ('renders nowhere in the user's
    transcript') — that is the accurate description in this
    environment.

Tool doc / prompt now state the silent contract explicitly: the
result only reaches the statusline, the call is a silent side
effect, do not narrate / echo / quote / explain.

Tests/typecheck unchanged (purely documentation and prompt wiring,
no behaviour moved).

* fix(hooks+state): F1-F5/F7 (per-turn sentinel, default fallback, source tagging)

Lost the previous F1-F5 fixes when I used 'git reset --hard' to
rebase F6 onto a clean origin/fix/react-channel. Reviewer (Opus 5)
flagged the regression; this restores the fixes with TDD discipline:
tests written FIRST, verified to fail against the prior code, then
the fix.

F1 — per-turn sentinel (CRITICAL). The 60 s wall-clock freshness
gate lost tool reactions on any turn longer than 60 s, which is
ordinary for agentic coding (90 s-5 min). Stop hook now writes
its run time to '.last_stop_hook.<sid>' after each invocation; a
tool reaction is treated as 'from this turn' iff its timestamp is
more recent than the previous Stop hook run.

F2 — clock skew guard. Tool timestamps implausibly in the future
(beyond 60 s) are treated as clock-skew garbage and the hook falls
through to the comment / pool branch. 'lastStopRun' on the writer
side is clamped to min(lastStop, now) so a future-dated marker does
not poison the comparison. ('react.ts' now distinguishes a future
tool timestamp from a legitimate one instead of rejecting both as
'age < 0'.)

F3 — saveReaction contract. Default source flipped from 'tool' to
'fallback'. writeStatusState no longer calls saveReaction
implicitly — every hatch / pet / unmute call path now writes its
own provenance explicitly, so /buddy stats can no longer lie about
canonical 'tool' lines being canned.

F4 — pool-write hooks tag their provenance. react.ts,
mood-react.ts, file-type-react.ts, name-react.ts all now write
'source: fallback' alongside reaction / timestamp / reason. Legacy
files without the field default to 'none' on load.

F5 — bookkeeping respects the rate limit. 10 short turns inside
the default 30 s cooldown window now produce 1 turn-counter
increment and 2 detached bun boots, matching main's behavior.
Bookkeeping still fires on the fresh-tool-skip path (so the stop
marker is stamped) but does NOT increment turns or spawn XP /
memory on the path where the tool already wrote the reaction.

F7 — literal '{lines}' dropped. The line '*nervous laughter'
{lines} lines changed.' in the large-diff pool would render
literally as the hook only substitutes {files} and {branch}.
Removed. A new test scans every DEFAULT_REACTION_POOLS entry
for any unsubstituted {name} token and asserts none (catches
future regressions of this class).

F9 — atomic write pattern. saveReaction now uses tmp + rename on
the reaction file (torn reads were a sub-bug in the freshness
gate's read). The Stop hook does the same on reaction /
cooldown / stop-marker writes. New test asserts no leftover
'reaction.*.tmp.*' files remain after a hook run.

Tests added (all written before the fix and verified to fail):

  server/hooks/buddy-comment.test.ts:
    F1 long-turn: 5-min tool reaction is preserved
    F1 fresh-tool: skipped even within default cooldown; bookkeeping
       does NOT spawn (F5)
    F2 future-clock: 5-min-future tool timestamp is clobbered
    F5 cooldown: 10 rapid-fire turns → events.turns === 1
  server/hooks/{react,mood-react,file-type-react,name-react}.test.ts:
    F4 each hook's reaction file has source: 'fallback'
  server/state.test.ts:
    F3 saveReaction without source defaults to 'fallback'
    F3 writeStatusState does NOT clobber an existing source='tool'
       reaction in reaction.$sid.json

Tests / typecheck:
  bun test:    369 pass / 0 fail
  bun run typecheck: clean

Branch state: rebased onto origin/fix/react-channel (e38ef95).
F6 statusline-only framing from a5d2bd1 is preserved verbatim —
the maintainer's directive (reactions are statusline-only, not
echoed in the transcript) is intact across the changes in this
commit.

Refs #124, #154.

---------

Co-authored-by: coding-buddy dev <dev@local>
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.

Per-turn <!-- buddy: --> comment now renders visibly in Claude Code v2.1.169+

2 participants