●WORKFLOWS — Dynamic workflows are GA in Claude Code: Claude plans the work, runs hundreds of parallel subagents in one session, and verifies outputs before reporting back●SCALE — Paired with Opus 4.8, Claude Code can carry codebase-scale migrations across hundreds of thousands of lines from kickoff to merge●ULTRACODE — The ultracode setting, via the effort menu, sets effort to xhigh and lets Claude decide automatically when to use a workflow●EFFORT — claude.ai adds an effort control beside the model selector; pick extra (xhigh in Code) or max for harder, long-running tasks●WFSIZE — A Dynamic workflow size setting in /config controls how large Claude makes workflows (small, medium, or large agent counts)●OPUS48 — Claude Opus 4.8 remains generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work●WORKFLOWS — Dynamic workflows are GA in Claude Code: Claude plans the work, runs hundreds of parallel subagents in one session, and verifies outputs before reporting back●SCALE — Paired with Opus 4.8, Claude Code can carry codebase-scale migrations across hundreds of thousands of lines from kickoff to merge●ULTRACODE — The ultracode setting, via the effort menu, sets effort to xhigh and lets Claude decide automatically when to use a workflow●EFFORT — claude.ai adds an effort control beside the model selector; pick extra (xhigh in Code) or max for harder, long-running tasks●WFSIZE — A Dynamic workflow size setting in /config controls how large Claude makes workflows (small, medium, or large agent counts)●OPUS48 — Claude Opus 4.8 remains generally available, improving on 4.7 across coding, agentic skills, reasoning, and knowledge work
Keeping Unattended Logs Readable — Measuring axScreenReader's Plain-Text Rendering Before Adopting It
Piping Claude Code into a log file leaves you with escape codes and redraw debris instead of an answer. Here is how I enabled axScreenReader across its three entry points, measured the difference on one real task, and moved my unattended runs over to it.
A task I had left running overnight had failed, so the first thing I did that morning was open the log.
What I found was almost 400KB of text in which the same lines appeared dozens of times over. Spinner frames. Progress indicators overwritten in place. Escape sequences for color. The one thing I needed — which command ran, and how it went wrong — was buried somewhere in that sediment.
Even grep did not help. The same tool call matched over and over. I could not tell whether it had actually run nine times or thirty-seven.
As an indie developer who leaves a few sites worth of work running unattended overnight, whether the morning log is readable feeds straight back into how much time I have left to build. An hour spent isolating a cause is not the price of the failure. It is the price of how it was recorded.
The problem was not what Claude Code had said. It was how it had drawn it. The axScreenReader option that landed on 2026-07-15 switches rendering to plain text, and while it is presented as an accessibility setting, it turns out to be exactly the right tool for keeping unattended logs readable.
Logs bloat because of drawing, not because of output
Claude Code's terminal display does not append lines. It moves the cursor back, erases what it already wrote, and writes again. While a person is watching, this is the right choice — progress updates in one place and your eyes stay still.
Down a pipe, though, every step of that process survives as history. Cursor moves (ESC[A), line erasures (ESC[2K), color codes (ESC[38;5;…m). A spinner redrawing ten times a second leaves ten lines of debris per second in your log.
So the bloat is not Claude being verbose. It is the redraw work — done for a screen nobody was looking at — recorded verbatim. Miss that distinction and you start reaching for the wrong lever: changing --output-format, trying to make the model say less. That is exactly what I did first, convinced I had let the model say too much, hunting for savings in a place that had none.
The way to separate the two is straightforward: count the body after stripping escape sequences, and count raw bytes separately.
Three ways to turn on axScreenReader, and how to choose
There are three entry points. Any one of them flips the behavior, but they differ in blast radius.
Entry point
How
What it affects
Best for
CLI flag
claude --ax-screen-reader
That invocation only
One-off reproduction; controlled A/B measurement
Environment variable
CLAUDE_AX_SCREEN_READER=1
The process and anything it spawns
CI and scheduled runs — no need to rewrite call sites
Settings
"axScreenReader": true
Every launch that reads those settings
A machine or container dedicated to unattended work
I use the environment variable. My unattended runs already go through a wrapper script, so it costs one line there, and my interactive sessions keep the display I am used to. Putting it in settings moves the default for the whole terminal, which gets confusing on a machine where you also work by hand.
The question to ask is simply how far you want the change to reach. Comparing? Use the flag. Changing only the unattended lineage? Environment variable. Machine already dedicated to unattended runs? Settings.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why TUI redraws and escape sequences inflate logs, and how to separate that from actual output with measurement
✦The three ways to enable axScreenReader (flag, env var, settings), what each one touches, and when to pick which
✦A line-oriented ledger that folds plain-text output into something you can actually read after a failed run
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Don't go by feel. Run one identical prompt under both conditions and count.
#!/usr/bin/env bash# capture.sh — run the same task under two conditions and measure readabilityset -euo pipefailPROMPT="${1:?usage: capture.sh <prompt-file>}"OUT="${OUT_DIR:-./_capture}"mkdir -p "$OUT"# Pin the width. If wrapping moves, line counts stop being comparable.export COLUMNS=120run_and_measure() { local label="$1" local raw="$OUT/${label}.raw" local plain="$OUT/${label}.plain" # script(1) supplies a pseudo-TTY. A plain redirect makes Claude Code treat # the run as non-interactive and change how it draws, so the two conditions # would no longer be comparable. script -q -c "claude -p \"$(cat "$PROMPT")\" --output-format text" /dev/null \ < /dev/null > "$raw" 2>&1 || true # Build the "body": the same text with control sequences removed. perl -pe 's/\e\[[0-9;?]*[A-Za-z]//g; s/\e\][^\a]*\a//g; s/\r//g' "$raw" > "$plain" local bytes_raw bytes_plain esc lines uniq bytes_raw=$(wc -c < "$raw") bytes_plain=$(wc -c < "$plain") esc=$(perl -ne '$c += () = /\e\[/g; END { print $c + 0 }' "$raw") lines=$(grep -c '' "$plain" || true) uniq=$(sort -u "$plain" | grep -c '' || true) printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ "$label" "$bytes_raw" "$bytes_plain" "$esc" "$lines" "$uniq"}printf 'label\traw_bytes\tplain_bytes\tesc\tlines\tuniq_lines\n'( unset CLAUDE_AX_SCREEN_READER; run_and_measure tui )( export CLAUDE_AX_SCREEN_READER=1; run_and_measure plain )
The script(1) wrapper matters because Claude Code renders differently depending on whether its output is a terminal. Redirect naively with claude ... > log.txt and you get non-interactive output — which is not the log your unattended runs are actually accumulating. If your scheduled work runs under a pseudo-TTY (a CI job runner, a script-based wrapper), measure under one too.
Pinning COLUMNS is the same idea. Change the wrap width and the line count changes with it, and line counts stop meaning anything.
Here is what one real task looked like on my machine — reading a handful of MDX files and checking them for consistency.
Condition
Raw bytes
Body bytes
ESC count
Lines
Unique lines
tui (default)
412,830
61,204
9,118
1,842
496
plain (axScreenReader)
74,102
71,946
214
638
601
These numbers move with your environment and your task. Rather than trusting this table, run capture.sh against your own workflow.
Reading the results — don't stop at the byte count
Raw bytes fell from 412KB to 74KB, an 82% drop. Pleasant, as far as it goes.
But the body grew from 61KB to 72KB — up 17%. There is more actual content in the plain-text run. The TUI folds and elides long output to fit a screen; plain-text rendering just lets it through. Summarize this as "the logs got smaller" and you have thrown that fact away.
The number worth reading was the unique-line ratio:
tui: 496 / 1,842 = 27%
plain: 601 / 638 = 94%
Three quarters of the TUI log is the same lines redrawn. That ratio, not the byte count, is what decided whether the log could be read afterward. Bytes are an outcome, not a metric.
You can confirm the practical effect with a trivial count. "Before" is the naive pass over the raw log:
# Before: counting the raw TUI log (redraws get counted repeatedly)$ grep -o 'Bash(' _capture/tui.raw | grep -c ''37# After: counting plain-text output (matches the real number of calls)$ grep -o 'Bash(' _capture/plain.plain | grep -c ''9
That is precisely the "nine or thirty-seven?" question from that morning. I did not fix how I was counting. I started recording in a form that could be counted.
Folding plain text into a ledger
Once output lands as honest lines, you can fold it line by line — run it through a machine before a human reads it.
#!/usr/bin/env node// ledger.mjs — read a plain-text log line by line and fold it into JSONLimport { createReadStream } from "node:fs";import { createInterface } from "node:readline";const [, , file] = process.argv;if (!file) { console.error("usage: node ledger.mjs <plain.log>"); process.exit(2);}// Keep classification rules here and nowhere else. Output wording shifts// between versions, so there should be exactly one place to fix.const RULES = [ { kind: "tool_use", re: /^\s*\S{0,2}\s*(Bash|Read|Edit|Write|Grep|Glob)\((.*)\)\s*$/ }, { kind: "error", re: /\b(Error|error:|failed|Traceback)\b/ }, { kind: "permission", re: /\b(permission|approve|denied)\b/i }, { kind: "result", re: /^\s*(Done|Completed|✓)\b/ },];const counts = new Map();let lineNo = 0;const rl = createInterface({ input: createReadStream(file), crlfDelay: Infinity,});for await (const raw of rl) { lineNo += 1; const line = raw.trimEnd(); if (!line) continue; const hit = RULES.find((rule) => rule.re.test(line)); const kind = hit ? hit.kind : "text"; counts.set(kind, (counts.get(kind) ?? 0) + 1); // Emit only classified lines; plain text is counted and dropped. if (kind !== "text") { console.log(JSON.stringify({ line: lineNo, kind, text: line.slice(0, 300) })); }}const total = [...counts.values()].reduce((a, b) => a + b, 0);const textRatio = ((counts.get("text") ?? 0) / total) * 100;// Always print the unclassified share. A jump here means the rules went stale.console.error( `summary total=${total} unclassified=${textRatio.toFixed(1)}% ` + [...counts.entries()] .filter(([k]) => k !== "text") .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `${k}=${v}`) .join(" "));
Why print the unclassified share every time? Because Claude Code's output wording can change between versions. When a rule quietly stops matching, the ledger starts returning a comfortable lie — "zero errors." Watching the unclassified ratio, and only revisiting the rules when it jumps, means you find out that it broke.
Run through this ledger, the log from that failed night folded down to two permission lines and one error line. A job of reading 400KB became a job of reading three lines.
Split the setting between attended and unattended
When I adopted this, I did not flip the machine. I split it by lineage.
# ~/bin/claude-unattended — unattended runs go through this wrapper#!/usr/bin/env bashset -euo pipefailexport CLAUDE_AX_SCREEN_READER=1export COLUMNS=120STAMP="$(TZ=Asia/Tokyo date +%Y-%m-%d_%H%M%S)"LOG_DIR="${LOG_DIR:-$HOME/logs/claude}"mkdir -p "$LOG_DIR"RAW="$LOG_DIR/${STAMP}.log"claude "$@" > "$RAW" 2>&1STATUS=$?# Build the ledger whether it passed or failed. Recording only failures# leaves you with nothing to compare "normal" against.node "$HOME/bin/ledger.mjs" "$RAW" > "$LOG_DIR/${STAMP}.jsonl" \ 2> "$LOG_DIR/${STAMP}.summary"exit "$STATUS"
The trailing exit "$STATUS" is there so that the ledger step cannot repaint the task's own exit code. Letting logging concerns contaminate the real verdict makes every later diagnosis harder than it needs to be.
Building the ledger on success too is about having a baseline. Record only failures and you will not know whether tool_use normally sits near ten or near fifty — which means you cannot tell whether tonight was abnormal. Keeping the summary lines from successful runs side by side did more for me than any single failure log.
When I run claude by hand, it is the TUI as before. Progress updating in one place really is easier for a human to follow, and there was no reason to break that.
Four things that are easy to misread
A drop in raw bytes is not "less output"
What shrank is redraw debris, not content. The body may well grow. Keep the storage conversation and the readability conversation apart.
Don't measure with a bare redirect
claude ... > log.txt produces non-interactive output that does not match what your unattended runs accumulate. If they run under a pseudo-TTY, measure under script(1).
Wrapping still depends on terminal width
Plain text is still wrapped text. Without pinning COLUMNS, line counts drift day to day and comparisons quietly stop working.
Don't grow the rule set
The finer the rules, the more silently they break when wording shifts. Isolate them in one place, watch the unclassified ratio, and touch them only when it jumps. That has held up longest for me.
What to do next
Start by running capture.sh against one task you actually run overnight. Ignore the raw byte count and look at the unique-line ratio. If it is well under 50%, that log is already unreadable — you just haven't needed to read it yet.
Once you have that number, swap exactly one unattended entry point over to a wrapper. Moving the machine-wide default can wait until the lineage has run for a few days.
That an accessibility setting turned out to be the cleanest fix for unattended readability was a genuine surprise to me. The audience differs, but the thing being made legible — the shape of the output — turns out to be the same.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.