Repository navigation
Fencing, redaction, hook and runner fixes from session review - #44
Open
cocotte1992 wants to merge 92 commits into
Open
cocotte1992 wants to merge 92 commits into
cocotte1992 wants to merge 92 commits into
Conversation
| utimesSync(stalePath, old, old) | ||
| // A stale previously-materialized copy in the OS temp dir: the materialize step's best-effort sweep must remove it (prefix-confined, age-gated), proving temp files from prior calls do not accumulate forever. | ||
| const stalePath = join(tmpdir(), `token-goat-shrink-999999-0-staletest${Math.random().toString(36).slice(2)}.jpeg`) | ||
| writeFileSync(stalePath, 'stale') |
… a parent npm exports `npm run` and `npx` hand user config to their children as npm_config_* variables, and npm 11.17 refuses allow-scripts arriving that way with EALLOWSCRIPTS, so on a machine that sets allow-scripts in ~/.npmrc the nested `npm audit --json` returned an error object and the guard threw reading `metadata` off it. The audit now runs with that one inherited variable dropped, which the child reads back from ~/.npmrc itself, and an error reply skips the check the way an unreachable registry already did instead of crashing.
…ical-path index tests The markup linearity guard failed under machine load and passed alone. What failed were the two critical-path stopwatches: the built CLI's index of the pathological component was held under 10 s and the worker drain under 5 s. With other worktrees running tests the CLI step took 11.9 s and 18.7 s, and here it took 3.5 s on a quiet machine and 7 to 52 s with the file running four to six times in parallel beside CPU burners. Those bounds timed process startup and the machine's load, not the scan. The critical-path tests keep their wiring assertions, that the default worker drain and the built CLI both index the file and resolve realAttr, and drop the stopwatches. The extractor both paths run on a .cmp file is held linear on that exact shape by the .cmp growth case.
…n Windows hints.git_hint_max_ms defaulted to 50 ms, and on Windows every git call it bounds takes longer: rev-parse --abbrev-ref HEAD, status --porcelain and diff --stat HEAD measured 57-184 ms, in a 645-file repo and in a non-repo temp dir alike. So the per-prompt branch hint, the subagent no-changes check and the compaction git bonus never ran there. Each timeout also killed only the cmd/git.exe launcher, leaving the real git running in the caller's directory; that is what failed tests/compact.test.ts with EPERM when it removed its project dir. The default is now 500 ms, about 2.7x the slowest call measured, which still keeps a UserPromptSubmit hook well inside hooks.latency_budget_ms. tests/git_hint_default_timeout.test.ts drives the real branch-hint handler against real git under the shipped default; every earlier test of that wiring mocked runGit. A missing hint is retried on a fresh session up to three times, because a loaded full suite can hold one call past 500 ms; a 20 ms budget still fails all three.
…y shot The built-bundle OCR smoke test only accepted a data:image/jpeg or data:image/webp shrink, a pair left over from sharp. The pure-TypeScript engine that replaced it keeps the smaller of its JPEG and PNG encodes and never writes WebP, and for white text on black the PNG wins, so the test failed on a correct shrink.
The Playwright CLI and the Playwright MCP server save every page's accessibility tree as page-<timestamp>.yml under .playwright-cli/ or .playwright-mcp/. The non-git walk already skips hidden directories, but index lists a git repo through git ls-files, so a repo that committed its snapshots indexed and embedded them; in this repo 8 of the 20 hits for search "updatedToolOutput WebSearch" came from two such files. isToolOutputPath, in skip_scope.ts, is now checked beside isParseSkipEligible at every site that decides what reaches the index: cmdIndex, the worker's indexer and drain, and the read commands' indexFileSyncPinned, each of which purges a file it refuses. It is kept out of parser.ts on purpose: every byte of parser.ts is in PARSER_FINGERPRINT, and moving that stamp would make every user reparse every language on upgrade. Since no fingerprint moves, no upgrade reparse clears snapshot rows an existing index holds, so reconcile re-queues an indexed snapshot as drift and the worker's drain purges it; an unindexed one is not reported as added. tests/tool_output_dirs_not_indexed.test.ts covers cmdIndex over a real git repo, the worker's default drain, reconcile purging a row an older index holds, and the built bundle's index plus symbol.
…empty fence pdf-extract on a scanned or image-only PDF printed an empty untrusted-content fence and exited 0, with no hint; pdf-meta already reported "Text layer: no" for the same file, but pdf-extract never looked. An agent spent three calls doubting the command before it rasterized the pages itself. extractPdfText trims every page before joining them, so an empty result means no requested page holds a visible character, the same test pdf-meta applies. Then pdf-extract prints no fence, writes a note on stdout naming the pages with a pdftoppm and a Ghostscript command that rasterize them for Read or image-text, and exits 1 with the reason on stderr, so a || fallback fires and 2>/dev/null still shows the route. The pdf-extract matrix case runs the built bundle on a generated image-only page; docs/cli.md describes the new exit.
…t hide them A read command's miss went to stderr only, Did you mean list and Try: line included. 14 of about 50 recorded agent calls ran these commands with 2>/dev/null; piped into head, two misses printed nothing at all, and in a third the || fallback then put a raw file holding a live Shopify token into context. section, read, symbol, brief, outline and skeleton now repeat the lines after a failure's first one on stdout as well, escaped as stderr escapes them, and still exit 1. That is where every miss puts its guidance: the near names, the Try: command, the file a symbol is really defined in, or the qualified commands an ambiguous name needs. A one-line miss and a --json body print nothing extra. tests/read_miss_suggestion_on_stdout.test.ts runs each command on the built bundle with stderr discarded.
section a b read a and printed "Note: 1 extra spec argument ignored", exit 0, so agents chained one section per heading: 14 spawns across three recorded commands, about 1-2 s each. The comma form that serves them in one call was in --help but not in the skill agents load. Specs that each name a heading are now served the way their comma form is, through runSectionCrossFile, but paired from the specs themselves rather than a comma-joined string, so a heading that holds a comma stays whole. Specs that cannot merge run one after another, one block each, and the call succeeds when any does. Words a shell split out of one unquoted heading keep the note that says how to quote it. The token-goat skill (src/canonical_skill.md, regenerated) gains one failure-shape line naming both forms; section --help and docs/cli.md say several specs read like the comma form. tests/cli_extra_positional_args.test.ts drops section from the commands that note extra specs and pins the new reads, and the section matrix case covers two specs on the built bundle.
…e is no usable slice skill-body --compact printed the full body without a word whenever a skill had no compact slice: humanizer's marker cuts 97.6% of its body, and marketing and token-goat have no marker, so they came back byte-identical. The Skill reload deny advertised --compact for every skill regardless, and one session spent six calls loading one skill. usableCompactSlice, in skill_cache.ts, returns the marker's slice only when it is at most half the body, the ratio the hook's cold-load inline gate already required. Without one, skill-body --compact prints the heading tree the hook inlines in the same case (planHeadingTree), names a skill-section command for a real heading, and says on stderr that no slice was served; a skill with too few headings to map gets its body back, said as such. The full body is unchanged without --compact. The reload deny, the heading-tree deny and the bare pointer deny now name skill-body --compact only for a usable slice, so no deny points at a command that would not serve what it promises. tests/hooks_skill.test.ts pins those texts; fixtures meant to be size-gated slices are lengthened so they stay under half their bodies. tests/skill_body_compact_no_slice.test.ts and the skill-body matrix case run the built bundle; the hint-template guard lists the heading argument the fallback's skill-section command carries as a reviewed safe value; docs/cli.md describes the fallback.
… inline limit, paged with a continuation Claude Code shows a Bash result inline only up to about 30,000 characters; past that it persists the result to a file and shows the model a 2 KB preview (measured on 2.1.29x: 26,588 chars came back inline, a 29,926-char stdout was persisted). token-goat's own answers had no ceiling: section "CLAUDE.arch.md::Security Boundaries" returned 52.5 KB, skill-body humanizer 49 KB and bash-output --file --grep 48 KB, and each cost further calls reading the persisted file back. HARNESS_INLINE_CEILING_CHARS (24,000, in overflow_guard.ts) caps them, lowered by overflow_guard.max_tokens and switched off with overflow_guard.enabled like guardText. section, skill-body and skill-section cut at a line boundary and name the token-goat read "file@A-B" that returns the next lines, its range sized to fit the ceiling too; a multi-section answer splits the ceiling across its sections, and the MCP section tool passes none. bash-output, web-output and mcp-output cut whole rows inside the fence, close it, and name below it the same recall with --lines A-B and its narrowing flags kept; a single line longer than the ceiling keeps its head and points at --grep, or --json-query for an MCP result. --full still prints everything. bash-output --grep with no --max-matches, --head, --tail or --full now stops at 100 matches with the --lines continuation for the rest, instead of the first 30 and last 80 behind a marker that pointed at --full; tests/cli.test.ts pins the new bound. tests/output_ceiling_continuation.test.ts runs each of the six commands on the built bundle, then runs the continuation it printed and checks it returns the next lines; the bash-output matrix case checks the cut. The truncator, display-sink and hint-template guards classify the new cut and its continuation command.
… for a file with nothing to name A whole-file cat, bat, type or Get-Content was refused at any size while the Read tool took the same file, and across the sampled refusals of 0.8-12.6 KB notes the agent read the file whole anyway six times out of eight. Below the Read gate's own threshold for the type (hints/file_type_handler.ts, 20 KB for every type the Bash hook checks) the dump now runs as written, as does a sed or awk range over the whole file; an .env file keeps the refusal at any size, and a multi-file cat is refused for its files past the floor only. The whole-file ladder printed a placeholder whenever no name was found: section "file::SectionHeading" for a heading-less doc, config-get "file" "KEY_NAME" for an env or flat config file, a grep for <key> or <pattern>, read "file::table_name" for SQL, all of which exit 1 or need editing first. Each now leads with token-goat read "file::1-80" through one helper, which hint_target.ts's sectionOrRangeCommand now shares. Tests that asserted a placeholder, or used a fixture smaller than the new floor to provoke a refusal, are updated to the new command or padded past the floor.
…me, and let a parsing read run where the cap cannot extractPythonFileRead scanned the whole compound command as one snippet whenever it started with python, so the open() inside a script a quoted heredoc was writing (python a.py && cat > b.py <<'EOF' ... EOF) read as a file read and the command was refused. It now splits the command into simple commands, takes the heredoc bodies in the order their openers appear, scans only a Python command's own snippet, and treats a heredoc as Python only when the python reads its program from stdin. A Python command whose standard output goes to a file prints nothing into the conversation, so it is no longer refused or rewritten; a redirect of another stream, a duplication or a /dev/stdout target still counts as printing. Where the capped run cannot ship (compression off, auto mode, a rule that withholds the rewrite), a read that hands the file to json.load, json.loads, csv.reader, csv.DictReader, yaml.safe_load, load_workbook or pd.read_* and never prints its raw text now gets the advice as a hint instead of a refusal; a read that prints the file keeps the refusal. cappedInterpreterRead takes the fallback output, so the node and PowerShell branches pass their refusal unchanged. The python heredoc branch that only matched a whole command of the form python - <<TAG is gone, since every heredoc now reaches the shared snippet scan, and eslint-suppressions.json drops the backtracking suppression its regex carried.
…s, before a token-goat read The Bash hook's cross-reference for a token-goat read said the file "was already fully read via the Read tool this session" whenever wasFileReadThisSession held, which a Read of 30 lines of a 1,069-line file is enough for. It now keeps that note for a whole-file read only; after ranged reads it names the windows on record, and for a line-range read (@a-b, ::A-B) only the windows that overlap it, saying nothing when none do. The two tests that set the note up with a ranged Read and expected the full-read wording now expect the partial one.
…izes, times or permissions ls -la dist/ | head, ls -lR, eza --long and tree -h drew "Use token-goat map --compact", and map prints files and symbols, never the metadata a long listing asks for. extractDirectoryListing now declines a listing whose first command carries a metadata flag of its tool (ls -l/-h/-t/-S/-s/-i/--full-time and kin, eza --long/--git/-l and kin, tree -h/-s/-p/-D and kin), bundled short flags included; a listing of names alone still gets the hint. The eza --long pattern that only ever matched a long listing is gone, and eslint-suppressions.json drops the backtracking suppression it carried. The tests that asserted a map hint for ls -la, ls -lR, ls -laR and eza --long now assert none, beside new positive cases for ls -a | head and ls -aR. The hook entry's eager set grows by 788 bytes to 2,596,163 over 60 chunks, 8,651 over the dist_chunks_deduped ceiling, so the ceiling is re-based on the measurement plus the usual 4 KB and its doc comment names what bought the bytes.
…les on Claude Code for Windows
Claude Code's Bash tool on Windows fails a command of about 7,300 characters or more before any of it runs ("unexpected EOF while looking for matching" a quote, exit 2), and token-goat's PreToolUse answered {} to every one: across the measured sessions every heredoc command of 7,963 characters or more failed. Git Bash itself, spawned with bash -c, cuts the argument near 8,190 characters and writes the heredoc truncated with exit 0.
On Claude Code for Windows, a command that opens a heredoc and whose length, counting three more for each apostrophe and one more for each double quote or backslash, reaches 6,500 has each quoted heredoc's body moved into a file, and the operator, body and closing line become < '<file>' with the rest of the command as written. The heredocs come from the PowerShell adapter's lexer, now exported as topLevelHeredocs and taught the <<\TAG form; text outside the bodies where that lexer and bash could disagree, and a delimiter bash would read as a longer word (<<'EOF'x), leave the command alone.
The rewrite goes through shellRewrite and permissionNeutralRewrite like every other shell rewrite, and ships only when that decision approves it, which in practice is bypassPermissions: a rewrite left to a prompt would ask the user to approve a command whose body the prompt cannot show. A body holding anything redactSecrets would redact is never written. Each body goes to a new file under ~/.token-goat/heredoc_spill, the directory created 0700 and the file opened exclusively with mode 0600, so an existing path is never written through and two spills of one body never share a file; spills older than an hour are removed whenever another is written.
Where the rewrite cannot ship, a command of 8,000 characters or more is refused with the reason and what to do; below that it runs as written.
README.md, docs/security.md and CLAUDE.arch.md name the new rewrite and what it writes to disk.
src/bash_heredoc_spill.ts is the 502nd TypeScript module under src/, past the 501 that parser_stamp_resolver_is_unhashed pinned, so that population is re-pinned at floor 470 and ceiling 520.
The hook entry's eager set grows to 2,602,003 bytes over 60 chunks, past the dist_chunks_deduped ceiling, so the ceiling is re-based on the measurement plus the usual 4 KB and its doc comment names what bought the bytes.
…rmissions mode, and read nothing before the rule decision On Claude Code an image Read outside the session cwd was always left unshrunk: preReadImageHandler passed before any shrink work, and decideRead approved only an original inside the cwd. In bypassPermissions no Read prompts, so that gate guarded a prompt that never comes, and the image went to the model at full size. In the mined corpus 112 of 119 shrinkable image Reads sat outside the cwd, in the Claude scratchpad or on another drive. decideRead now takes the permission mode and, in bypassPermissions, hands the verdict to decideRewrite's bypass branch, which approves only with no PermissionRequest hook and a clean hidden-rule check. The deny/ask Read-rule scan over the original and its real spellings, blockReadsOutsideWorkingDirectories, and the UNC guard in preToolPathDeclined still run first. Every other mode keeps the cwd gate, and Copilot CLI keeps it in every mode. The shrink also read and cached the original before any rule was consulted: it decoded the image and wrote token-goat-shrink-* into ~/.token-goat/image_shrink_cache, then permissionNeutralRewrite skipped the rewrite and deleted only the temp copy. With a Read deny rule on the image, Claude Code refused the original while the cached copy, which no rule covers, stayed readable. This held at base for images inside the cwd in every mode, and the bypass change above would have extended it to images outside it. preReadImageHandler now asks readHintCrossesRule (the same rule scan, blocked reads outside the cwd, unreadable settings) before it stats, looks up the cache or reads the image, and writes the cache only once the rewrite or the host context ships. CLAUDE.arch.md, README.md and docs/hook-channel-matrix.md name the bypass case where they list when the image Read is approved, and the invariant now says nothing is read or cached before the rule decision.
… path, never as context text The fallback in finalizeShrinkResult returns the summary and data URL as context with no size guard, and the mined corpus showed 119 Claude Code reads where such a data URL was persisted behind a 2 KB preview. At HEAD that fallback no longer reaches Claude Code: the shrink rewrites the Read path there, and serializeOutput drops any shrink payload on Claude Code. On Copilot CLI, OpenClaw and pi the data URL is the transport the host bridge writes the copy from, and each bridge strips it before anything reaches the model, so a size cap on it would end the shrink on those harnesses (a probe cap at 9,000 characters failed six bridge tests). The new test drives a real shrink through the Copilot adapter and asserts what Copilot receives: a modifiedArgs path, no additionalContext and no data URL, while the hook's own answer carries the data URL past 10,000 characters.
preReadHandler also runs for Grep, and the first-read size gate on tasks/*.output and tool-results/*.txt sized the whole file whenever the call had no limit, which a Grep never has. A pattern search on a 337 KB tool-results snapshot was refused as a whole-file Read and pointed at bash-output, whose own output then spilled. A Grep now skips that gate and its advisory: it returns matching lines only, bounded by head_limit, the narrow form the refusal asks for. A whole-file Read of the same file is still refused.
… refused for size The file-type gate sizes a ranged Read by its slice, but handleTxt, handleJsonl and handleGenericLarge did not know the request was ranged. A Read of a .out with offset=1, limit=66 was refused with "use Read with offset/limit params", the slice's bytes beside the whole file's line count, and a preview of the whole file's first and last lines, so the advice repeated what the call already did. hooks_read.ts now passes the requested lines on with the sized slice. A ranged refusal says which lines were asked for and how large they are, names the largest limit under the cap from the slice's average line length, and previews that slice's first and last lines; a log keeps its bash-output --grep alternative, and a slice whose lines are each over the cap points at byte sampling. A file the session wrote through the Write tool is identifiable (its session entry is edited with no read), but the two refusals seen live were on files a shell redirect created, which the session does not record, and a Write-created file's content is already in context, so the size refusal still applies to both.
…PDF to the Read handlePdf said "Read cannot return PDF content; this is not retryable with different Read parameters", which is false on Claude Code, whose Read returns PDFs through its pages parameter. The refusal is token-goat's: it now says every PDF Read is refused because the extracted text costs far fewer tokens than the page images, keeps the pdf-meta, pdf-outline and pdf-extract route, and says a page whose layout matters can be rendered to an image and read. A Read of a PDF that had been deleted was refused as "PDF file (0 B)". The file-type dispatch now runs only when the path stats as a regular file, so the Read goes ahead and reports the missing file itself.
The note after the third ranged read of one file told the reader to prefer token-goat skeleton or a symbol read for any file, a .txt or a log included, where neither has anything to show. The note is now gated on isSourceExt, as the fourth-slice paging refusal above it already was.
A completed Read of a memory note came back with "... rest of paragraph folded" and was unfolded with sed in the very next call, 2 of 2 times in the corpus. Of two agent-written 18-26 KB scratchpad reports read in parallel, one came back as a partial view cut to its headings and had to be fetched again. They differed on OUTLINE_MAX_REPLACEMENT_RATIO: in Claude Code's numbered layout every withheld line still costs its line-number prefix, so the heading outline of findings_hooks.md came to 27% of the rendered body and shipped, while that of findings_markers.md, made of many short lines, came to 51%, was declined, and fell through to the paragraph fold. postReadHandler now runs no outline, skeleton or fold for a file under Claude Code's memory folder (<config dir>/projects/<project>/memory/) or its session temp tree (claude/ under CLAUDE_CODE_TMPDIR or the system temp dir, where the scratchpad lives, as claude.exe 2.1.296 builds it), or for a markdown document under MARKDOWN_SIZE_THRESHOLD, the 4,000 bytes below which the read hook does not look at a document's structure. A large document elsewhere is still cut down. The served-line elision is unchanged. The shipped-defaults guard's prose-fold fixture was under 4,000 bytes, which this change stops folding on purpose; it is padded past the floor inside a fence the fold never reads. isolate-home.ts drops an inherited CLAUDE_CODE_TMPDIR, and the session-env guard lists it as scrubbed there. README.md says these files are returned whole. The hook entry's eager set grows by 1,212 bytes to 2,606,121 over 60 chunks, 22 over the dist_chunks_deduped ceiling, so the ceiling is re-based on the measurement plus the usual 4 KB and its doc comment names what bought the bytes.
…and redaction reach the model
Claude Code validates updatedToolOutput against the tool's own output schema, and WebSearch's is {query, results, durationSeconds, searchCount} with summary strings inside results. The post hook sent its fenced copy as a bare string, so all 223 recorded rewrites from 2026-09-23 to 2026-10-07 were rejected ("expected: object, code: invalid_type, path: []") and the model saw the original result, unfenced and unredacted.
postWebSearchHandler now also builds updatedToolResponse: a copy of the result with each string element of results redacted and fenced on its own, and every string inside a hit object (title, url, snippet) redacted and stripped of the web fence tag and of token-goat's own marker, since hit objects reach the model as "Links: <json>" outside the fence. Keys, nesting and the other top-level fields are untouched. serializeOutput emits it on Claude Code in place of the shaped string; other harnesses still read updatedOutput. On Claude Code the secret_redacted count is booked from the shaped copy that ships ('counted-elsewhere' to emitRewrite), not from the fenced whole-result string the harness never receives, and a result with nothing to change passes. neutralizeFenceMarkers is now exported from injection_scan.ts for this.
shapedUpdatedToolOutput no longer falls back to a bare string for a built-in tool when no field resolves. It returns null and serializeOutput emits {} (the context goes too, since it describes a rewrite that is not happening). MCP tools, and calls with no event to judge, keep the string.
The shape is checked against Claude Code 2.1.296's WebSearch output schema as transcribed from the installed claude.exe bundle. The secret-redaction guard now reads updatedToolResponse and drives Claude Code's real WebSearch shape, with a credential in the summary and, separately, in a hit URL; redaction_stat_counts_what_is_emitted checks the booked count equals the placeholders that ship.
…it of a file worth re-reading surgically The hint fired 50 times in the recorded corpus, 32 after a Write and 18 after an Edit, and was followed 0 times. After a Write the model has just supplied every byte and Claude Code already says the file state is current, so the hint could save nothing; on a 1-2 KB scratch file a whole re-read costs less than the line asking for a section. postEditHandler now passes after a Write, and markdownSectionHint speaks only when the edited file is at least hints.reread_deny_min_bytes (51,200 by default), the size at which token-goat already judges a re-read costly enough to deny. A smaller file is booked as a declined detection, as the 512-byte floor already did. edit_reread_suggest leaves SUPPRESSION_HINT_CATEGORIES. Its text names a section command, so running that command is the proof it worked; scored on absence, every ignored hint was booked as compliance and the category could never be retired by shouldSuppress. Tests that used it as their example of an absence-scored category now use read_reread_dedup, and the post-edit fixtures in hint_target, headingless_section_hint_runs, hint_undisplayed_detections, wire_format_contract_matrix and the path-injection guard now drive an Edit of a file over the threshold.
…on after a day idle The advisory was already meant to fire once per session, but its mark lived only in the session-state blob, and the worker's cache sweep deletes a blob 24 hours after it was last written. A session left for a day and resumed drew it again: C--Projects-Analysis 9557924b showed it three times, on 2026-09-29, 10-03 and 10-06, the last two each after an idle gap of more than a day (43.1 and 68.3 hours). buildUnrestrictedSpawnAdvisory now also claims a durable flag in global.db through stats.ts::claimSessionFlag (INSERT OR IGNORE into stats_flags, keyed by flag and session id) and stays silent when the claim was already taken. A failed claim falls back to the in-memory check. pruneSessionFlags ages the rows out on the stats retention, run by the existing stats maintenance pass. The pre-dispatch population pin in pre_handler_fs_touches_are_gated moves 344 -> 345 for pruneSessionFlags, which recordStat's maintenance pass now reaches.
…empty project, and never tell a subagent to index 10 of 63 recorded spawn briefings described a project of 0 to 4 files, such as "# Project map: LCF2026 / Files: 0 / ## Top symbols: none ... run 'token-goat index . --walk'", so every subagent spawned there was handed an instruction to index the parent's directory. buildSubagentBriefing now adds the map only when the walk found at least BRIEFING_MAP_MIN_FILES (5) files, and renders it with formatProjectMap's new suggestIndex: false, which keeps the "no files indexed" line but drops the command. The reminder, the report contract and the cached-output ids are unchanged, and the map, baseline and MCP callers still print the index command. The agent-spawn, VS Code and Copilot subagent-start fixtures now hold five source files so their briefings still carry a map.
…s report
An Agent call with run_in_background: true returns a launch receipt, {isAsync: true, status: 'async_launched', agentId, description, resolvedModel, prompt, outputFile, ...}, with no content blocks. extractToolResultText serialized the whole receipt, about 12 KB of it the prompt the model had just written, and postAgentHandler cached that and announced "This subagent report (12KB) is cached for later recall" on all six background spawns in the orchestrator session.
postAgentHandler now treats an async launch receipt like a report under agent_report.min_bytes: nothing is stored and no recall hint is added, while the unrestricted-spawn advisory, which is about the spawn, still applies. Completed reports are cached as before.
…eated tab list at any size @playwright/mcp answers in markdown ### sections, not JSON, and 903 of 1,160 measured results were under the 2,000-byte compression floor, so postMcpHandler only fenced them. browser_evaluate's "Ran Playwright code" block re-quotes the model's own function argument (34% of its bytes), and "Open tabs" repeated the previous list in 83 of 212 sections. A new section pass, mcp_compress_playwright.ts, replaces a code block that is byte-equal to the call's own function or code argument (quoted exactly as Playwright quotes it; all 499 measured browser_evaluate blocks matched) with a one-line size note, and collapses an Open tabs list identical to the last one shown this session to a note plus the (current) tab, through session.ts's existing tab-list fingerprint. Result, Page, Error and every action tool's code block pass through byte-identical. postMcpHandler runs the pass below the floor, on the redacted text, and ahead of the error and dedup returns so the tab record never misses a list the model was shown; the packs and the generic pass keep the floor and run on its output. The browser pack now recognises browser_network_requests and cuts an overlong query string in its numbered text lines past 200 chars, and truncateSnapshotLine handles Playwright's YAML node form (quoted name and inline text), leaving refs, attributes and /url lines intact. The new truncators are classified in the truncate-before-redact guard, with straddling-key regressions through the live hook. The hook entry's eager set grows by 5,244 bytes to 2,614,478 over 60 chunks, 4,261 over the dist_chunks_deduped ceiling, so the ceiling is re-based on the measurement plus the usual 4 KB and its doc comment names what bought the bytes.
…ler acts on, and keep doctor --repair from narrowing the matchers to Read and Grep
The installed PreToolUse matcher carried ^mcp__, so Claude Code started a hook process for every MCP call although preMcpHandler acts only on calls isMcpReadOnly passes and preScreenshotHandler only on screenshot tools; all 1,409 measured PreToolUse mcp__* runs printed {}.
Each handler now declares a toolPattern built from the source of its own decision: mcp_cache.ts's READ_VERBS list builds both READ_VERBS_RE and MCP_READ_TOOL_PATTERN, and hooks_screenshot.ts's SCREENSHOT_TOOL_PATTERN is both the handler's regex and its matcher fragment. anyCaseSource spells letters as two-letter classes because a hook matcher is case-sensitive and both handler tests ignore case. PostToolUse keeps ^mcp__. On the measured sessions 947 of 1,184 MCP calls no longer start the PreToolUse hook.
doctor never compared an installed matcher with the one the build derives, so an existing install kept ^mcp__ with no row and no repair. staleHookMatchers reports the token-goat-only groups whose matcher differs, through the same renarrowedMatcher rule installHooks's re-narrow applies; doctor shows a "Claude Code hook matchers" row for them and doctor --repair reinstalls.
The doctor process never loaded the hook modules, so the registry toolMatcherFor reads was partial: a doctor --repair that rewrote the hooks left PreToolUse as ^Read$|^Grep$ and PostToolUse on the catch-all (reproduced on the 2.9.30 bundle). cli_doctor_hooks.ts now imports relay.ts before any repair, and runDoctorChecks imports it before the matcher row is computed.
The closing tag of an untrusted-content fence was a fixed string, so keeping the fence shut depended entirely on the escape recognising every spelling a model reads as that tag. Each look-alike or invisible character the escape did not know was a way out, and the audits kept finding more. fenceUntrustedContent, fenceUntrustedFileContent and fenceUntrustedOcrText now draw 48 random bits per fence and write them unquoted in both tags, `<untrusted-tool-output nonce=0123456789ab>` and the closing tag with the same value. The bytes being fenced exist before the nonce is drawn, so none of them can be the real closing tag. Unquoted because a fence often travels inside a JSON string, where a quote is escaped. Claude Code marks the uploads it hands a model the same way. neutralizeOutsideFences matches a region only by an opening tag with a nonce and a closing tag repeating it, finding every closing tag in one pass so openers that each carry their own nonce stay linear. A fence-shaped region without a nonce is treated as plain text and its notice is escaped. closeCutFence and notesSection in manifest.ts and fenceFoldedFile in fold_delivery.ts spell the close from the open, and extractJsonFromMcpOutput accepts a closing tag with a nonce. The escape still runs, since a model does not check the nonce. Each fence costs 38 more bytes; the Bash net-benefit fixtures in the tests move by that amount. tests/fence_nonce.test.ts covers every builder, the parsers, the linearity of the closing-tag lookup, and the built bundle on the MCP post hook and bash-output recall; the existing fence tests compare through a helper that drops the nonce.
…s terminator stripAnsiEscapes ended a DCS, SOS, PM or APC string at the first ESC so that a line of `ESC P` with no terminator could not make it quadratic. A string whose payload holds an ESC then matched only as the bare `ESC P`, and its payload reached the model as text: tmux's passthrough of a clipboard write left `tmux;` and the clipboard data inside every tool-output fence. The control-string alternative now runs lazily to the string terminator, ESC bytes included, but only over the text up to the last terminator, where one is sure to be found. The text after it is stripped without that alternative, since no string can end there. No match crosses the cut: a CSI holds no ESC, and an OSC or control string ends at or before the last terminator. tests/ansi_control_string_strip.test.ts covers tmux passthrough and the other three string kinds through stripAnsiEscapes and withoutEscapes, unterminated strings, and five linearity shapes.
…olds a BEL fencedFileBody kept a C1 control that introduces no escape sequence, so `don` U+0092 `t` came back as written. Under a strip that rewrites every C1 control as ESC and its 7-bit final, U+0085 becomes ESC `E`, and the ansi-regex 6.1 string branch takes ESC `E`, the letters after it and a BEL as one span: `x <` U+0085 `B` BEL `/untrusted-file-content>` assembled an exact closing tag. When the text holds a BEL, every C1 control now counts as an escape byte and is written as U+241B with the rest; without a BEL they are kept as before. A mock-API capture of Claude Code 2.1.296 shows it passes C1 controls and BEL to the model unchanged, so no real strip is known to assemble this; the rule closes it for any strip of that kind at the cost of files holding both. tests/file_fence_c1_with_bell.test.ts runs every non-introducer C1 control before letters and a BEL, in the tag and in the voice, through that hypothetical strip.
fencedFileBody kept a file's escapes only when every ESC began an SGR colour code, so `ESC(B`, which `tput sgr0` and ncurses write after a colour reset, came back as U+241B and an Edit built from that view missed the file. The kept set now also holds a complete designation: ESC, an optional `$`, one of the designators `( ) * + - . /`, and a letter, or a digit followed by nothing the ansi-regex pattern reads as more parameters or a final byte. Every strip that removes a designation removes exactly those bytes, and one that does not leaves the designator, a visible character that can break a marker but not complete one. `ESC(0` before a letter stays substituted, since the 6.1 pattern takes the letter with it. A new test runs 266 designations, each before 13 suffixes, through the tag, in-name, voice and numbered-Read shapes under every modelled strip.
…y bracket instead neutralizeTags and neutralizeSpokenMarkers read every character of the text they fence, at a cost that is linear but up to fifteen times higher for a look-alike than for an ASCII letter, and nothing bounded the length, so a multi-megabyte body set the cost of the hook that fenced it. Past FENCE_SCAN_CEILING (4 Mi UTF-16 code units, beyond any Read and any context a fenced body could fit in) both now read nothing: escapeEveryBracket replaces every character that reads as `<`, `>` or `[` with its entity in three regex passes. tests/fence_scan_bounded.test.ts counts character reads instead of timing them: twelve adversarial shapes stay under twelve reads per character at two sizes across a tool and a file fence (the worst measured is 8.5), and a text past the ceiling is fenced with no reads and every bracket escaped.
…es out on Windows On Windows the runner's timeout called child.kill() on the bash.exe it spawned, which terminates that one process. The command bash was running, and anything it had started, kept going after the runner reported exit 124, and on the captured path the run also waited for them to close the output pipe before returning. killProcessTree runs taskkill /T /F on the child's pid while the shell is still alive, so the walk can reach its descendants, and both run paths call it from their own timer, as does the capture cap. The --no-compress passthrough path used spawnSync's timeout, which returns only after the shell is dead, when there is no tree left to walk; it now spawns asynchronously, so runRaw returns a promise. tests/bash_runner_timeout_kills_tree.test.ts runs a shell whose foreground job records its pid and waits a minute, and checks that the process is gone after the timeout, in-process through run() and through the built bundle's compress --no-compress. Both cases failed before the change: the captured run did not return until the child exited, and the streamed run left it alive.
… -v keeps them GNU grep puts a filename in front of a line only when it searches more than one file. GrepFilter read the text before each line's first colon as the line's file whenever it held a dot or a slash, and grep -v makes that the usual case because its lines are whatever the file holds. A grep -v over one shell script was summarised as 40 matches across 6 files, with awk patterns listed as files and none of the script's lines kept. For grep, egrep and fgrep the filter now works out the file operands itself (a pattern supplied by -e or -f is not one, and a flag's value is skipped), and when the search cannot have printed filenames (one file or stdin, no -H, no recursion, no operand spelled as a directory) every line belongs to that file, so the result ships as lines. rg, ag, ack and git grep keep the prefix reading, since their rules differ. An inverted search's summary now counts lines rather than matches. tests/grep_inverted_match_lines.test.ts drives a grep -v result through the post-Bash hook and through the filter for one file, stdin, two -e patterns and a recursive search. Three of its four cases failed before the change. The hook entry's eager set reaches 2,657,207 over 60 chunks, 1,019 over the dist_chunks_deduped ceiling once this lands on the fence hardening and the process-tree kill, so the ceiling is re-based on the measurement plus the usual 4 KB and its doc comment names what bought the bytes.
…d-in When hint_target.ts resolved no heading, symbol or key, it returned a placeholder name (SectionHeading, SymbolName, KEY_NAME, table_name, or a site's own such as Heading or DescribeBlockName), and many hints spliced their own stand-ins into commands: '<key>', '<selector>', '<pattern>', '<heading>', '<path>', '<range>'. An agent runs the command a hint prints, and each of these exited 1 or searched for a string no file holds; the grep structural hint, fileQueryHint, the lock file deny, the skill denies, the MCP oversized notice, the file-type handlers and the skeleton fold all did this. HintTarget is now a union whose unresolved member has no name, so the compiler refuses a hint that pastes one, and sliceCommand takes only a resolved target. Every site now names the real part, or leads with a command that runs on any file of that kind (outline, skeleton, a read of the first lines, xml-outline, lockdeps for the lock files it parses, a JSON or YAML file's first top-level key, slide 1, page 1), and drops the targeted clause when nothing resolves. tests/guards/hints_carry_no_placeholder.test.ts walks every src module the hook entry can load, through static and dynamic imports short of the command surface, and fails on a stand-in name after ::, a <word> argument inside a token-goat command or after a flag, or a bare <word> passed to a call. It listed about 40 such literals before the change and none after, apart from an allowlist that gives the reason for each remaining match. Hook tests that pinned the old stand-ins now assert the runnable fallback and that the stand-in is gone.
…e the paging ceiling from it delivery_cap.ts held the limit at 20,000 bytes, from an older capture, while overflow_guard.ts held token-goat's paged answers to 24,000 characters because results up to 26,588 had been seen inline. Measured across this machine's transcripts on Claude Code 2.1.220 to 2.1.296, the longest Bash result delivered inline held 29,706 characters and every persisted result whose saved file survived held 30,695 or more; a transcript's toolUseResult stdout for a persisted result is the truncated text, so only the saved file gives its size. CLAUDE_CODE_BASH_OUTPUT_CAP_BYTES is now 29,706, the measured lower edge of that gap, with the provenance on the constant, and keeps its name for the fitting code that imports it. HARNESS_INLINE_CEILING_CHARS is the same value less a 4,096-character reserve for the continuation notice and stderr, so the savings counterfactual, the rewrite fit and the paged answers read one number. tests/delivery_cap_measured_limit.test.ts fails on the old values: a 25,000-byte result was credited as a 2 KB preview, a 25,000-byte rewrite was clipped, and the ceiling did not follow the limit. The delivery cap tests restate the new boundary, and docs/cli.md names the new paging size.
…s them The opencode plugin sent only opencode's built-in tools through post_tool_use, so every MCP tool result reached the model with no untrusted-content fence and no secret redaction. opencode 1.18.16 passes tool.execute.after the MCP CallToolResult itself and builds the model's text from its content array after the hook returns, so rewriting output.output would not have reached the model either. The plugin now names the call mcp__<server>__<tool>, sends its content blocks to post_tool_use, and writes the rewrite back into content, keeping image and blob blocks. read_mcp_resource and the two resource-listing tools carry a remote server's text in output.output, and are now rewritten there. An MCP result marked as an error is still not reachable: opencode throws its text before any plugin hook runs. docs/security.md and docs/hook-channel-matrix.md record that gap.
The hook channel matrix listed Qwen's post rewriteOutput channel as unverified. Qwen Code 0.25.1's firePostToolUseHook reads only additionalContext, artifacts and the stop decision from a PostToolUse response, and updatedToolOutput appears nowhere in its bundle, so a rewritten result is dropped. The cell now reads D (w) with a footnote citing the bundle.
…al control string Since the strip removes a DCS, SOS, PM or APC string whole up to its terminator, `ESC P ghp_... ESC[31m ... ESC \` held no token in the escape-stripped view and held it in two halves in the text as given, so redactSecrets stored it raw and hasPreciseSecret let a Read fold ship it. Redaction and hasPreciseSecret now read a third view, withoutEscapesKeepingStrings, which removes CSI, OSC and single-character escapes but keeps a control string's payload, as the strip did before it took control strings whole. redactSecrets returns the first view that found a credential the text hid, so the fence view keeps priority. The regression test drives the audit's exact input through redactSecrets, hasPreciseSecret and the built bundle's Bash hook, and fails on every case without the new view.
…letter, digit or quote The Read-fold post-pass that put back `[tg]` and `[token-goat:` written inside a line counted any letter, number, punctuation or symbol before the marker as visible text. The Hangul fillers U+3164, U+115F and U+FFA0 and the blank Braille pattern U+2800 are letters and symbols that draw nothing, so a line starting with one got a raw marker restored at what reads as the start of a line. The post-pass is gone, and the rule now lives in the neutralizer's file path. inlineMarkers leaves a spoken marker as written only when an ASCII letter, digit or quote stands before it on its line, past any Read column, counting the line from the last break a renderer starts a line at or from a backslash, so a project note shown with a backslash and `n` for its line break still starts a line there. A line holding a control character other than a tab or a line break, or U+241B, keeps every escape, and so does every marker after one already escaped on its line. Every file fence gets the rule, not only Read folds. fenceFoldedFile and fenceFoldedNumberedFile are removed with the nonce parsing they needed to find the fence body, the fold call sites go back to fenceUntrustedFileContent and fenceNumberedFileContent, and the substitution guard drops the wrapper entries it had for them. The strip oracle in fence_strips_ansi_before_neutralizing now fails on a raw marker that opens a line after a strip, rather than on any raw marker, since a marker behind text is the shape a file fence keeps. The tests cover each of the four blank characters through the fence and an in-process Read fold, U+3164 through the built bundle, and fail on the previous tree. The pre-dispatch population guard is re-pinned at 362 (floor 320) for the two functions added to the relay closure.
…ead of shipping them unfenced The browser pass's image-only branch in postMcpHandler returned the whole block array as updatedBlocks. It is reached when blocksToText extracts no text, yet a block can still carry words it does not read: a resource_link whose only text is its title. That block reached the model beside the shrunk image with no fence and no redaction. The branch now keeps only token-goat's own notes and the non-textual blocks, the same filter the fenced path applies to its preserved blocks. A test drives an image plus a title-only resource_link through relay's buildEvent and the registry and through the built bundle, and fails without the filter.
… MCP rewrite Both copies of the Copilot postToolUse translation, the installed shim's translate() and the resident adapter's copilotTranslate, copied binaryResultsForLlm into modifiedResult verbatim. copilot-sdk's convertMcpCallToolResult puts every blob resource there whatever its mimeType and sets its description to the resource URI, so a text/plain blob or a description written by the MCP server went back beside the fenced text with no fence and no redaction. Each entry is now kept only when its mimeType is image/* or audio/*, and only as type, data and mimeType. Whether Copilot 1.0.96's native runtime shows a text blob or a description to the model was not determined from the binary, so neither is sent. Tests drive both the adapter through the real relay and the shim against the built bundle with a described image, a text/plain blob, a PDF blob and an audio blob, and fail on either copy without the filter.
… carries an output string The opencode plugin recognised an MCP result by a content array with no output string beside it. opencode 1.18.16 and 1.19.0 pass tool.execute.after the bare CallToolResult, so that held, but a version that also copied its rendered text into output would have made the plugin skip the call entirely, and the server's text would reach the model with no fence and no redaction. A content array now marks the result on its own, and the rewrite is written into output as well as content when both are present. Checked against opencode 1.19.0: an MCP result marked isError is still thrown as the tool's error before any plugin hook runs and reaches the model as the call's errorText. Only the experimental chat.messages.transform hook sees that text, and it runs over the whole history before every model call with no session id, so the gap stays documented rather than patched. structuredContent needs nothing: opencode turns it into a text block before the hook when content is empty, and shows only content otherwise. docs/hook-channel-matrix.md and docs/security.md now say so. A test drives a result with both fields through the plugin's real hook and fails without the change.
The hook channel matrix still listed Qwen's post context cell as unverified from docs. Qwen Code 0.25.1's bundle reads hookSpecificOutput.additionalContext from a PostToolUse response, escapes its angle brackets and appends it to the tool's llmContent, and serializeOutput writes a post_tool_use context to that field for qwen, which the wire-format contract matrix already asserts against the built bundle. The cell is now M (w) and footnote 37 cites the bundle functions and the token-goat side.
…re cap The runner's timeout and capture cap killed only the /bin/sh it spawned on Linux and macOS, so the command the shell was running and anything it started kept going after exit 124. On the captured path a survivor also held stdout open, so the run did not return until it exited. killProcessTree now reads the shell's descendants from /proc, or from ps where there is no procfs, before signalling the shell, then sends each of them SIGKILL, matching taskkill /T /F on Windows. The shell is not spawned detached and killed as a process group: Node's detached also calls setsid, which takes the command out of the terminal's session, so Ctrl-C would stop reaching it under --no-compress and prompts that open /dev/tty would fail. The tree-kill test now runs on every platform. It failed on both paths under WSL Ubuntu before the fix and passes after it.
…ich still gets the data URL The image-shrink change that stopped sending a generic harness the shrunk image as base64 left two built-bundle matrix cases on TOKEN_GOAT_HARNESS_OVERRIDE=generic. The hook now answers those with an empty object, so the sharp regression case found no "smaller" and the OCR smoke case found neither OCR text nor a data URL. Both cases now pin OpenClaw, one of the hosts that turns the data URL back into an image, so they exercise sharp and the OCR wiring through the shipped bundle again. The generic-harness empty answer stays covered by tests/image_shrink_text_only_harness.test.ts.
…s, not a launch race The installed native SessionStart test sent its input after 1500 ms and then required the hung helper to have started. The native relay reads the whole input before it creates the owner, the shim and the helper, so those launches had only the few hundred milliseconds left before the relay's cleanup reserve, and the test failed whenever process creation was slow. It also could not see the property in its name: with the shim patched to restart its deadline at its own start, the relay still ended the run on time and the test passed. The delayed-input case keeps its outcome and elapsed checks and now only requires that a helper it did start is gone. A new case runs without the delay and has the shim record the deadline it hands its owner: that value must equal the native entry's --session-start-deadline and fall before the shim's own process start plus 3000 ms, which fails against the restarting shim and does not depend on how fast anything launches.
… before deleting it cleanupTrees decided a process was gone with process.kill(pid, 0), which on Windows reads only the exit status. Windows sets that status when the process is terminated, before the kernel closes its handles and unmaps its image, so the fixture delete ran while the dying process still held its executable or working directory and failed with EPERM under load. Teardown now opens each owned PID, plus every descendant taskkill /T reports, and waits on the process handle with Process.WaitForExit before deleting. Wait-Process is not enough because it asks HasExited first, which reads the same status. The new regression test parks a killed holder in that state with a debugger attached to it, so the window stays open for as long as the test needs: the old teardown fails it with EPERM every time and the new one passes.
…ll handling under Unreleased Adds the series' user-facing entries to the Unreleased section: 4 under Added, 3 under Changed, 13 under a new Security heading and 51 under Fixed. Each entry states the net change of the whole series rather than the step that introduced it. The OCR entry names the final tesseract.js 7 and core 7 pair, the inline-marker entry describes the ASCII letter, digit or quote rule, the delivery entries use the measured 29,706-character limit and the 25,610-character paging ceiling, and the redaction, look-alike, file-fence, Copilot attachment, opencode, WebSearch and compress-timeout entries each fold in the later items that refined them. Entries for intermediate states, such as the tmux passthrough strip and the blank-character marker bypass, are left out, since no release shipped them.
…es before deleting them Several test teardowns killed or stopped a process, decided it was gone with process.kill(pid, 0), and deleted the directory it used. On Windows that probe reads only the exit status, which is set before the kernel closes the process's handles and unmaps its image, so the delete could still hit EPERM. The worker teardowns hid this behind rmSync maxRetries and a swallowed error, which could also leave the directory behind. The wait the Copilot startup tree fixture used now lives in tests/helpers/process_exit.ts. awaitProcessExit waits on each PID's process handle with Process.WaitForExit, and childExit resolves on a spawned child's exit event, which fires on the same handle. awaitProcessExit takes the time each PID was last known to name the process meant, and passes over a process under that PID that started later. Windows reuses freed PIDs, and waiting on whatever unrelated process took one would block for ten seconds and then throw. The hook server, native hook, Copilot SessionStart stall, worker daemon, enqueue revive and runGit timeout teardowns now wait for real exit before deleting, and the worker teardowns delete without retries or a swallowed error. tests/process_exit_helper.test.ts parks a killed holder with a debugger attached so the window stays open: deleting on the liveness probe fails with EPERM every run, and both waits pass. A third case shows a reused PID is not waited on.
…a refused source re-read The per-file circuit breaker allows one re-read refusal per file per compaction epoch, so the second attempt in this test now gets the quiet advisory note instead of a second deny. The test still asserted two denies and failed on upstream as well. The test keeps its original invariant: a refused read hands the model nothing and is not counted, so the retry still quotes the two reads that reached the model.
…macOS machine without pwsh Every case in powershell_runner.test.ts past the resolver spawns pwsh, and none was gated, so on a Linux machine without PowerShell, such as the WSL distro the pre-push hook runs the suite in, 69 of them failed with exit 127. powershell_heredoc_compat.test.ts already skips its pwsh cases there. On CI they still run whatever canRunPowerShell says, as tests/helpers/powershell_parse.ts requires for its oracle, because GitHub's ubuntu, macOS and Windows runners all ship pwsh and a silent skip there would hide it going missing.
…GOAT_TEST_MAX_WORKERS TOKEN_GOAT_TEST_MAX_WORKERS outranks every other input to resolveMaxWorkers, and max_workers.test.ts read it from the environment it was started in, so a suite run with the knob set, the way it is meant to be used to run narrower, failed 7 of the 8 cases about the machine's shape. Each case now starts with the variable cleared and the caller's value is restored after it.
…mall WSL VM does not freeze The pre-push hook runs the full suite in WSL Ubuntu, which .wslconfig gives a fixed 4 GB and no swap. With 6 forked workers, and again with 2, memory ran out mid-run and the whole VM froze until wsl --shutdown, where a Linux machine with swap would only have slowed. A forked worker peaks past 1 GB (notes.test.ts seeds a million symbols) beside a parent of about 0.5 GB. On Linux resolveMaxWorkers now allows one worker per 2 GB of memory plus swap past the first GB, at least 1 and never more than it allowed before. vitest.config.ts passes SwapTotal from /proc/meminfo. The 4 GB VM gets 1 worker; every CI runner keeps its count, and Windows and macOS, which grow their page file and swap on demand, are unchanged.
… so the case runs without Developer Mode The broken-link case planted a file symlink, which Windows refuses with EPERM to a process that is neither elevated nor in Developer Mode, so on such a machine it failed before reaching the audit. Its neighbours that only need a directory link already use a junction there. A junction to a missing target needs no privilege and reaches the same broken-link diagnostic: realpath fails on it as it does on a dangling symlink. Other platforms keep the file symlink.
…of the line-range lock The line-range lock from 44e95a0 refused the last read of "is let through again after an overlapping re-read already found the edit" in tests/range_reread_after_file_change.test.ts, for both app.ts and server.log, on origin/main as well as on this series. After the edit was found, the probe window 15..25 became the file's only recent visit, and the re-read of 10..30 moved both ends by 5 lines, which the lock reads as a micro-shift retry. The probe window is now 28..60. It still overlaps the recorded 10..30 without covering the edited line 12, and it shares only lines 28..30 with the re-read, so the case again tests what it names: the change on disk lets the re-read of 10..30 through. With the reset in dropFileLineRangesIfChanged disabled, both cases fail again.
cocotte1992
force-pushed
the
session-mining-fixes
branch
from
October 14, 2026 07:31
f174c6d to
7b1a911
Compare
This branch has not been deployed
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.
91 commits on top of main (16b2588), each with its own regression test. The fixes come from reviewing recent Claude Code sessions that used token-goat, followed by an adversarial security review of the fencing and redaction changes. CHANGELOG.md's Unreleased section has the user-facing summary.
Main areas:
[tg]markers in files stay as written only behind an ASCII letter, digit or quote.grep -vover one file is parsed as lines of that file.npm ciafter merging.Known gaps, documented rather than fixed:
binaryResultsForLlmdescriptions to the model could not be verified; the bridge now keeps only image and audio entries.Validation: build, typecheck, lint and the guard tier ran on every commit. The last five commits fix what the pre-push hook found: a hooks_compact_epoch test left on the pre-circuit-breaker behaviour by cbb738d, PowerShell runner tests with no gate for a missing pwsh, an env leak in max_workers.test.ts, a WSL VM freeze from too many memory-heavy vitest workers on a 4 GB VM (Linux worker count now follows available memory), and cli_bootstrap_audit needing Developer Mode. With them the full suite passed on Windows (1366 files, 0 failed) and under WSL Ubuntu via wsl-test.sh (1357 files, 0 failed). Existing installs need
token-goat install(ordoctor --repair) to add the PowerShell PreToolUse matcher.Commits: