Fix the Claude Code hooks so they actually work (#327) - #331
Merged
Conversation
Neither hook has ever worked end to end, and nothing failed anywhere,
because the tests re-implemented the hooks' helpers inside the test file
and asserted on the copies.
Five defects, all fixed here:
1. session-start sent `?q=`; GET /recall reads `query` and answers 400
without it. Already corrected on v3; kept and now covered by a test
that replays the script's real URL against the real route.
2. session-end parsed stdin as `{messages}`. Claude Code sends hook
metadata with a `transcript_path`; the conversation is JSONL on disk.
The hook now reads that file backwards in blocks until it has three
human turns (1 MB ceiling), splitting on the newline BYTE so a block
boundary can cut a line but never a character, and keeps only
human-readable turns: tool_use, tool_result, thinking, sidechain,
isMeta, compaction summaries and harness noise prefixes are dropped.
3. No hook `timeout`. SessionEnd hooks share a 1.5 s budget raised only
to the largest per-hook timeout, and POST /capture waits on an
embedding and often a model call. The installer now writes
`timeout: 30`.
4. Both hooks exited 0 on failure and Claude Code drops stderr from an
exit-0 hook, so every failure looked like an empty brain. Any HTTP or
network failure is now one stderr line and exit 1; exit 0 is reserved
for "nothing to do".
5. install.sh swallowed a settings.json parse error into `{}` and then
overwrote the file, and re-running after removing its marker appended
duplicate hooks. It now reconciles — its own entries are replaced by
command-path match regardless of checkout path or the old
`VAR=x node …` form, everything else is preserved, a malformed file
is refused, and a backup is written before the atomic rename.
Shape of the fix: shared logic lives in common.js so the tests can
require the real code rather than a mirror, which is what let #327 hide.
Credentials move to ~/.config/second-brain/config.json (the file the CLI
and desktop app already use) so the token is not in settings.json and
not in `ps`. Capture is on by default but gated on content — one human
turn of 40+ chars and 200+ chars of conversation — and on the Worker
reporting version 3.0+ via a 24 h-cached /health check, so v3 scripts
against an un-redeployed Worker say so once a day instead of failing on
every session close.
New: fixtures/sample-transcript.jsonl (every line shape the reader must
handle), `install.sh --check`, `install.sh --uninstall`, and a README.
One deviation from the spec: its contract test set HOME and the hook's
cwd to the same temp directory, so parseProjectName correctly reported
no project (it treats $HOME as not-a-project, as its own unit test
asserts) and the tag-arm fallback case saw one request instead of two.
Real Claude Code always runs with cwd set to a project directory below
HOME, so the harness got the project directory it was missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The framed block only means anything if nothing inside it can counterfeit its edges. A memory whose own text contained `----- second brain notes (end) -----` printed a second, convincing closing line, and everything that memory said after it read as though the search results had already ended — the exact injection the frame exists to prevent, reintroduced by the content it wraps. Dash runs of three or more are folded to an em dash, so a delimiter line cannot be spelled from inside a snippet. Whitespace was already collapsed, which keeps every memory on its own numbered line, and the two together make the structure unforgeable: the only delimiter-shaped lines in the output are the two the hook wrote. The words survive; only their power to look like structure is removed. Found by testing the shipped frameOutput against a hostile memory rather than the friendly ones the suite had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against the real transcripts on this machine: a 3.7 MB agentic session produced a memory of eight assistant progress reports and not one user turn. The reader was right — it walks back until it has three human turns — but the formatter then spent the whole 2000-char budget newest-first, and the tail of a real session is almost entirely assistant narration, so the turns the reader had worked to find were the first thing dropped. The gate passed because it counts turns, not the body, so nothing complained. What the person asked and how the session ended are now reserved before the budget is spent on anything else, each with a fair share so one enormous prompt cannot crowd out the outcome. The remaining budget still fills newest-first. Re-measured over all nine transcripts here: every body that passes the gate now contains a human turn, and the 3.7 MB session reads as the two requests that drove it plus the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage Report
File CoverageNo changed files found. |
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.
Closes the client half of #327. Base is
327-claude-code-hooks, the issue branch offfeat/v3-team-edition.What was broken
The hooks under
integrations/claude-code-hooks/have never worked end to end since they shipped. Three defects, only two of them reported:session-start.jssent?q=;GET /recallreadsqueryand answers 400, and the script's ownif (!res.ok) returnswallowed it. Fixed on this branch already, but it never reached a release.session-end.jsparsed stdin as the transcript. Claude Code sends{session_id, transcript_path, cwd, hook_event_name, reason}and keeps the conversation in a JSONL file attranscript_path, somessageswas always empty and the script returned before/capture.install.shset notimeout, and Claude Code gives SessionEnd hooks a shared 1.5 s budget raised only to the largest per-hooktimeout.POST /captureawaits an embedding, a Vectorize query and often a model call before replying.Two more found while fixing those:
settings.jsonwith a comment or trailing comma was swallowed into{}and overwritten, takingpermissionsandenvwith it. Re-running after deleting the marker appended a second copy of both hooks rather than upgrading them.What this does
session-end.jsreads the hook payload, then walks the JSONL backwards in 64 KB blocks (1 MB ceiling) until it has three human turns, splitting on the newline byte before decoding so a block boundary can never cut a character. Keepsuser/assistanttext blocks only — notool_result,tool_use,thinking,isMeta,isSidechain,isCompactSummary, or harness markup. Leads with asession — repo@branch — date (reason)header, reserves the human's request and the outcome, then fills newest-first inside a 2000-char cap.session-start.jsasks for the project tag first and falls back to free text, scoped to the personal layer, and frames the result as data rather than instructions.~/.config/second-brain/config.json(mode 600) — the file the CLI and desktop app already share. The hook command is now plainnode <path>, which also fixes Windows, where the oldVAR=x node …form is a PowerShell syntax error, and keeps the token out ofsettings.jsonand out ofps.install.shreconciles by command substring instead of appending, refuses a malformedsettings.json(with a backup and a temp-file write), setstimeout: 30on SessionEnd andmatcher: startup|clear|compacton SessionStart, never prompts without a TTY, and gains--checkand--uninstall./healthcheck) and a real conversation — one human turn of 40+ characters and 200+ characters of talk.Testing
The old unit test re-implemented the hooks' helpers inside the test file and asserted on the copies, which is why
?q=survived for the life of the feature. The new tests import the real modules and run the real scripts.test/unit/claude-code-hooks.test.ts— 34 cases against the shipped code: block boundaries at 7/64/100/333/1024 bytes, mid-character splits, truncated trailing lines (the file is written asynchronously), the byte ceiling, every observed noise prefix, the gate, and the framing.test/integration/claude-code-hooks-contract.test.ts— spawns both scripts with the stdin Claude Code actually sends, records the requests, and replays each one through the real Worker. Covers the 401 path, the tag-to-free-text fallback, source skipping, the capability gate, and a slow server.test/integration/claude-code-hooks-install.test.ts— runs the installer against a tempHOME: upgrade in place, idempotency, malformed-file refusal, no-TTY exit,--uninstall, and a check that the real home directory is untouched.integrations/claude-code-hooks/fixtures/sample-transcript.jsonl— a scrubbed real transcript carrying every shape, because a synthetic fixture is how the previous test enshrined the bug.Full suite green (3240 tests),
tsc --noEmitclean,check:scopeclean. Verified end to end against a stub Worker and against the real multi-megabyte transcripts on this machine: a 3.7 MB session parses in 9 ms with no tool output, secrets, or markup in the captured body.Review notes
Two defects were found by testing the shipped code rather than the friendly cases, and are fixed in their own commits:
🤖 Generated with Claude Code