[Bug] REPL cat() returns empty string ("") on repeat reads instead of a sentinel — downstream scripts truncate/clobber real files

Status Open
Reported on v2.1.201
Maintainer reply ✓ Yes — bcherny
Activity 3 comments · opened Jul 4, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Environment

  • Claude Code 2.1.201, macOS (darwin 25.5.0), model claude-fable-5
  • REPL tool enabled via "env": { "CLAUDE_CODE_REPL": "true" } in settings.json

Summary

The experimental REPL tool's cat(path) shorthand dedupes against content it already returned earlier in the session: the first read returns the file content, but any subsequent cat() that would return the same content again returns an empty string — indistinguishable from a genuinely empty file. No error text, no sentinel. This holds across REPL calls and even within a single script.

Presumably this is a context-token-saving elision ("the model already has this content"), which is a reasonable goal — but signaling it with '' is destructive, because the REPL's documented contract is "shorthands never throw — they return the error text on failure". An elided read returns success-shaped nothing.

Reproduction (deterministic)

One REPL script:

o.reads = []
for (let i = 0; i < 5; i++) {
  const t = await cat('/path/to/any/existing/file.md', 1, 2)
  o.reads.push(t ? t.length : 'EMPTY')
}
o
// → [12, "EMPTY", "EMPTY", "EMPTY", "EMPTY"]

Cross-call variant: cat(f) in one REPL invocation returns the content; cat(f) in a later invocation returns '' while sh('wc -c f') confirms the file is unchanged on disk (reproduced with a 1444-byte file). If the file's content changes between reads (e.g. via the Write tool), the next cat() returns the new content in full — the elision keys on repeated identical content, not on the path alone.

Why this is worse than a display quirk

The empty string flows into whatever the script does next, and the model has no way to distinguish "elided" from "empty file":

  1. File truncation: const t = await cat(template); await put(target, t) — if the template was cat'd earlier in the session, t === '' and put() faithfully writes a 0-byte file. We hit exactly this in production use (a tracked rule file truncated to 0 bytes). For weeks this was misdiagnosed as "put() truncates files".
  2. Document clobbering: a session re-reads its own memory-index file, gets '', concludes the index is empty, and "rebuilds" it with only one line — destroying ~109 index entries. (We had precisely this failure; the elision is the most plausible trigger.)
  3. Any read-modify-write, diff, or compose pattern silently degrades the same way.

The failure surfaces far downstream of the read, so each incident tends to get blamed on a different layer (put(), sandboxing, external tools), which is why it kept recurring for us.

Expected behavior

Any of these would be fine — just not bare '':

  • Return an explicit sentinel consistent with the REPL's error convention, e.g. "[elided] unchanged since previous read of <path> — content already in context", so a guard like if (!t || t.startsWith('[elided]')) (or even naive string use) fails loud instead of silent;
  • or return the content again (correctness over token savings for a tool whose output feeds writes);
  • or make elision opt-in per call.

Notes

  • put() itself behaved correctly in all our experiments: it returns "[error] File has not been read yet…" for unread targets and writes faithfully otherwise — the "truncation" was it writing an elided empty read.
  • Couldn't find this in docs or existing issues (REPL appears undocumented; closest is #52175, unrelated).

View original on GitHub ↗

3 Comments

yurukusa · 3 months ago

This is a genuine data-loss bug, not a display quirk — you're right, and the truncation path (#1) is the dangerous part. Two things help while it's unfixed: a guard that stops the empty-read → 0-byte-write clobber, and a way to find files it already damaged.

The invariant that stops the clobber

The whole failure is that cat() can return success-shaped nothing — a '' indistinguishable from a genuinely empty file — and that '' then flows into a write. So never let a read drive a write without checking it against ground truth on disk:

async function safeRead(path) {
  const t = await cat(path)
  if (t === '') {
    const bytes = parseInt((await sh(`wc -c < ${JSON.stringify(path)}`)).trim(), 10)
    if (bytes > 0) throw new Error(`elided read: ${path} is ${bytes} bytes on disk but cat() returned ''`)
  }
  return t
}

Now const t = await safeRead(tpl); await put(target, t) can't silently write a 0-byte file — an elided read throws instead of clobbering, which is exactly the "return the error on failure" behavior the shorthand contract promises. If you can't wrap every call site, the minimal rule is: before any put(target, x) where x came from a read, assert x !== '' || (wc -c of the source) === 0.

Find what it already clobbered

Your memory-index case (109 entries → 1 line) is the scary one because it happened silently. If those files are in git, the damage is findable and recoverable — look for tracked files that are empty now but weren't in the previous commit:

git ls-files | while read f; do
  [ -f "$f" ] && [ ! -s "$f" ] && git cat-file -s "HEAD:$f" 2>/dev/null | grep -q '^[1-9]' \
    && echo "was non-empty, now empty: $f"
done
# recover: git checkout HEAD~1 -- <file>

For files not in git, the transcript is a fallback: the elided content was returned in full on the first read earlier in the same session, so the pre-clobber content is usually still sitting in ~/.claude/projects/<slug>/<session-id>.jsonl — grep that session's .jsonl for a distinctive line of the lost file and you can lift the original back out.

On the fix: agreed the root fix is to stop signaling elision with ''. Any sentinel that can't be mistaken for file content (a distinct marker object, or just re-returning the content) preserves the "shorthands never throw / return error text on failure" contract. '' is the single return value a read-modify-write can't defend against on its own — which is why this reads as a benign token optimization but behaves as silent truncation.

(Honesty note: I didn't reproduce the REPL elision itself — it's behind CLAUDE_CODE_REPL and I didn't enable the experimental tool. The clobber path and the guard/recovery above are the general read-integrity invariant — validate a read against on-disk size before it drives a write — which holds regardless of what makes the read come back empty.)
</content>

bcherny collaborator · 2 months ago

Thanks for the detailed report and the reproduction script.

I tried this on the released 2.1.233 build (macOS) with the REPL tool enabled, running your exact 5x cat('f.md', 1, 2) loop, and got [17, 17, 17, 17, 17] — the content came back every time. That's because the underlying "file unchanged since last read" deduplication in the Read tool is currently switched off for a portion of accounts, and the account I tested with is in that group. So I couldn't observe the empty string black-box, but reading the code confirms your analysis: when the Read dedup does fire, the REPL cat() shorthand maps the "file unchanged" result to "" instead of the file content or an error-style marker, exactly as you describe.

Classification: confirmed bug. cat() should either return the content again or a clearly non-content marker (consistent with the "shorthands never throw — they return the error text on failure" contract), never a bare empty string that a following put() will happily write to disk. We'll fix the shorthand so it can't produce success-shaped nothing.

Until then, your safeRead-style guard (treat "" from cat() as suspect when the file is non-empty on disk) is a reasonable workaround.

🤖 Generated with Claude Code

sstawiarski-ps · 1 month ago

Looking forward to this being fixed