Skip to content

fix(#842): total SessionStart byte budget, handoff redelivery cap, and a config-aware oversize notice - #845

Merged
fdaviddpt merged 2 commits into
mainfrom
fix/842
Oct 2, 2026
Merged

fdaviddpt merged 2 commits into
mainfrom
fix/842

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

SessionStart had no limit on its total output. Claude Code saves any hook output over roughly 10,000 characters to a file and shows the model only a short preview. On ordinary stores (11–14.7KB, measured by the reporter), that preview ends inside the handoff, so the whole MEMORY section (now, recent, today, archive) never reaches the model.

This covers requests 1, 3 and 4 from #842. Request 2 (session_start.inject_files) is deferred. The config-cache bug from the same report is #843 / #844.

  • Total budget (request 1): new thresholds.session_start_max_bytes, default 9000; 0 turns it off.
    • The handoff and memory output is collected into one variable before printing, so the budget can see the whole total.
    • If the total is over budget, whole sections are dropped, lowest priority first: archive, then today, recent, now. Each dropped file is listed by path and size, the way source=compact already lists files. Nothing is truncated partway through a file.
    • source=compact is unaffected.
    • The budget counts bytes, not characters. A byte count is never smaller than the character count, so this is the stricter of the two.
  • Handoff repeats (request 3): new thresholds.handoff_max_redeliveries, default 3. Once the same handoff has been shown in full N times, later starts list it by path and size instead of re-injecting it. The delivery count still goes up, and /remember still replaces the handoff as before.
  • Over-cap notice (request 4): when memory_inject_max_bytes is set below the bundled default, the notice says "capped by config" and no longer points to /remember:doctor.
  • Bad values: a malformed value in either new key is logged as a WARNING and falls back to the default.
  • Docs: docs/configuration.md and config.example.json. tests/test_config_contract.py passes.

Closes #842

Measured

On a test store sized like the one in the report:

  • Before: 12,890 bytes of SessionStart output, over the cap.
  • After: 6,740 bytes. Archive and today are listed by path and size instead of injected.

Testing

23 new tests, each written first and watched fail before the change: 11 of 23 failed then, and all 23 pass now.

  • tests/test_session_start_total_budget_842.py (11). Covers: under budget (control), drop order, dropped files listed with their size, total stays under the budget, every file just under its per-file cap, a store with lots of multi-byte text, 0 turning the budget off, a malformed value, and compact unchanged (control).
  • tests/test_handoff_max_redeliveries_842.py (8). Covers: below the threshold (control), listed instead of injected on the 4th delivery, the count still going up, a new handoff resetting the count (control), 0 turning the cap off, a lower configured threshold, and a malformed value.
  • tests/test_memory_inject_capped_notice_842.py (4). Covers: a lowered cap gets the new wording; at or above the default keeps the original wording (controls).
  • Full pytest: 2820 passed, 59 skipped, 0 failed.
  • Bash 3.2: wrapping the block in $( { … } ) broke two existing case blocks under the stock bash 3.2.57, a parse error caught while developing this. They now use the leading ( pattern form this file already uses elsewhere for the same reason.

Review notes

  • The captured block: about 360 lines of session-start-hook.sh now run inside $( … ). I followed the 38 functions called from it. No variable set inside is read after it, no background job starts inside it, and nothing inside writes to the real terminal output directly or calls exec or exit.
  • Not in the budget: output printed after the captured block. That includes the consolidation notice, the slow-start notice, and after_session_start plugin output. The default of 9000 leaves about 1000 bytes of room under the 10K cap for these.
  • Promo path: when a promo is showing, the output is wrapped in JSON, and escaping makes it larger. It's unknown whether Claude Code's cap counts the raw output or the additionalContext text.

Cross-platform claims

  • Observed on macOS with bash 5.3 and stock /bin/bash 3.2.57.
  • Reasoned, not observed, on Linux and Windows Git Bash. The three new test files are skipped on Windows (they need a bash hook subprocess), and are listed in docs/windows-skip-triage.md. CI covers the matrix.

🤖 Generated with Claude Code

[AI-generated]

fdaviddpt and others added 2 commits October 1, 2026 22:04
…d a config-aware oversize notice

SessionStart's handoff + REMEMBER legend + now/today/recent/archive sections
had no total cap, only memory_inject_max_bytes (per-file). An ordinary store
(six individually healthy files, 11-14.7KB combined) routinely crossed Claude
Code's own ~10,000-character preview/persist threshold, so the model never
saw === MEMORY === at all -- the preview cut off inside === LAST HANDOFF ===.

- thresholds.session_start_max_bytes (default 9000, bytes not characters --
  see the new _remember_apply_session_start_budget header for why bytes are
  the conservative choice). Fills in priority order handoff -> now -> recent
  -> today -> archive; whatever does not fit is listed by name and size,
  never silently dropped, reusing the same convention source=compact already
  uses for its own deferred files. Malformed values fall back to the default
  and are logged (#834's own convention). 0 disables the budget.

- thresholds.handoff_max_redeliveries (default 3): once the same unchanged
  handoff content has been delivered this many times, a further delivery is
  listed by path instead of re-injected in full -- re-sending the same ~2KB
  note forever was most of what was pushing stores over the new budget. The
  delivery record still advances past the cap, so a later redelivery still
  reports how long the note has been pending, and /remember still replaces
  it immediately.

- The memory_inject_max_bytes over-cap notice now says "capped by config"
  and drops the /remember:doctor suggestion when the configured cap sits
  below the bundled 200000 default -- that is a deliberate choice (often to
  stay under the new total budget), not evidence of a broken store.

Measured on a realistic fixture matching the issue's own repro: before this
fix, 12,890 bytes; after, 6,740 bytes with the lowest-priority sections
listed instead of injected.

Docs: docs/configuration.md and config.example.json document both new keys;
docs/windows-skip-triage.md records the three new modules' blanket win32
skip, same template and verdict as the existing session-start-hook.sh
subprocess fixtures they are modelled on.

Fixed a latent bash 3.2 parsing bug surfaced by wrapping the handoff+memory
render in command substitution: two pre-existing `case` blocks without a
leading `(` on their patterns (a `case "$_hkey" in fingerprint) ...` fed from
a `while read` loop, and `case "$DELIVERIES" in ''|*[!0-9]*) ...`) parse fine
standalone but not nested inside `$( { ... } )` under bash 3.2 -- added the
leading `(` this codebase already uses elsewhere for exactly this reason.

Deferred (per the brief): session_start.inject_files (request 2) and the
config-cache `-nt`-on-deletion bug (split to #843, scripts/log.sh and
scripts/lib-env-cache.sh -- untouched here).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
… the bundled cap

Review of #845 found three defects in the SessionStart budget; each now has
a failing-first test.

1. Quadratic cut. _remember_apply_session_start_budget located each memory
   section with ${text%%"$h"*} / ${text#*"$h"} over the whole captured body,
   which bash 3.2 and 5 evaluate in time quadratic in the body size, once per
   dropped section, and only ever on an over-budget (i.e. large) store.
   Measured on a ~300 KB store over the default 9000-byte budget: 55.3s
   (bash 5.3) / 45.7s (bash 3.2) before, 0.35s / 0.44s after.

2. Content steered the cut. The first "--- <basename> ---" match was taken
   anywhere in the body, so a header-shaped line in the handoff (file
   content, the #721 threat model) or in an earlier memory file moved the
   cut there: it removed the handoff's tail, its random-token END fence and
   "=== MEMORY ===", and listed files as dropped that were still injected.

   Both fixed by never searching content. The renderer now decides inclusion
   per section before emitting it (_REMEMBER_BUDGET_EXCLUDE, set only inside
   the budget's own render subshell, unset at lib load so the environment
   cannot inject it), and lists what it left out with the size it already
   measured. The head is recovered by length (body minus a fresh render of
   the MEMORY tail), trusted only after the tail compares byte-identical;
   a mismatch leaves the body as rendered and logs it. The under-budget path
   still returns at the length check: no new render, fork or copy.

3. "Capped by config" hid a malformed file. With memory_inject_max_bytes
   below the bundled 200000, every over-cap file was called deliberate,
   including a file larger than 200000 itself (the #346 shape). The config
   wording now applies only while every over-cap file is within the bundled
   default; past it, the original warning and /remember:doctor stay.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@fdaviddpt
fdaviddpt merged commit e42ef53 into main Oct 2, 2026
16 checks passed
@fdaviddpt
fdaviddpt deleted the fix/842 branch October 2, 2026 13:53
fdaviddpt added a commit that referenced this pull request Oct 2, 2026
… guess on v0.38.0 (#871)

* docs(#864): narrow (not confirm) the RUNTIME_FETCH_EXEC third-finding guess on v0.38.0

#861's rewording of pipeline/shell.py and scripts/log.sh did not clear the
directory portal's RUNTIME_FETCH_EXEC warning; v0.38.0 raised the count
from 2 to 3 instead. Which third file matched is only visible behind the
expanded row in the portal's web UI, which this lane has no access to, so
the real trigger stays unconfirmed.

Source-only follow-up: grepped the two largest v0.37.0->v0.38.0 diffs
(scripts/lib-memory-context.sh, scripts/session-start-hook.sh, both from
#842/#845) for curl, wget, download, fetch, eval and exec and found
nothing in either -- the existing doc guess that #842/#845's SessionStart
budget code is the new third match has no textual support. scripts/log.sh
is the one file here with real eval calls on validated input, the
strongest literal (still unconfirmed) candidate for why it keeps matching.

No code changed. docs/releasing.md records the narrowed state and flags
that a human with portal access still needs to expand the row and record
the file/line before this can be closed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* docs(#864): precise churn figures, clarify grep scope per self-review

Explore review flagged two accuracy issues in 4ccd6dd's docs/releasing.md
text: "+164"/"+74 lines" were git diff --stat churn totals (insertions
plus deletions), not insertion counts, misleading by ~2 and ~11 lines
respectively -- replaced with explicit insertions/deletions. The "found
nothing" grep claim was ambiguous between grepping the whole shipped file
vs only the diff's added lines; under the whole-file reading it is false
(scripts/session-start-hook.sh has 9 pre-existing `exec` hits). Clarified
that only the added (+) lines were grepped, gave the exact command, and
noted why the whole-file reading is misleading (those hits pre-date
v0.38.0 and cannot explain a count that only rose between versions).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
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.

SessionStart injection has no total budget and routinely exceeds Claude Code's 10KB hook-output cap, so MEMORY never reaches the model

1 participant