anthropics / anthropics/claude-code

[BUG] CLAUDE_CODE_EFFORT_LEVEL in .claude/settings.local.json latches at session launch; later writes, including `unset`, are read and discarded

Open
#95,240 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
146k
Forks
23.8k
PR merge metrics
PR metrics pending

Description

### Preflight Checklist

- [x] I have searched [existing issues](https://github.com/anthropics/claude-code/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug) and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [ ] I am using the latest version of Claude Code

Not on the latest: measured on 2.1.270, current is 2.1.274. Release notes for 2.1.271 through 2.1.274 carry no change to effort resolution, so I don't think the gap matters here. See Additional Information.

### What's Wrong?

`CLAUDE_CODE_EFFORT_LEVEL` set through the `env` block of `.claude/settings.local.json` latches at session launch. If the key is in the file when the session starts, that level is fixed for the whole session and every later write of the key is read and discarded. If the key is absent at launch, later writes are honored, repeatedly, for as long as the session runs.

The settings file is re-read either way. A second key written into the same file in the same write reaches the shell one request later; only `CLAUDE_CODE_EFFORT_LEVEL` is thrown away.

Writing the level `unset` does not release it. That is the documented way to hand control back, and it works for a pin the session set itself. Against a pin that was there at launch it clears nothing, and a normal level written after it is ignored like the rest.

This matters because a settings file that only takes effect on the launch you didn't control is, in practice, a setting that silently stopped working. Anything that writes the key in-session — an orchestration skill, a `UserPromptSubmit` hook, a cost-control plugin — works fine in a repository that has never been pinned and is a no-op forever in one that has. Nothing reports the difference. There is no error, the file on disk says what you asked for, and `/effort` still refuses with `Not applied: CLAUDE_CODE_EFFORT_LEVEL= overrides effort this session`, which reads as though the file is in charge.

### What Should Happen?

Either direction would be fine, as long as it is one of them:

1. A later write of `CLAUDE_CODE_EFFORT_LEVEL` applies from the next request, regardless of whether the key was present at launch. That is what already happens in the unpinned case, so this is making the two cases agree.
2. Effort is fixed at launch and later writes never apply. Then the unpinned case is the bug, and the file should say so — ideally by declining the write visibly rather than applying it silently for a subset of sessions.

At minimum `unset` should release a launch-time pin, since that is the only documented escape and there is currently no way out of the pin from inside the session.

### Error Messages/Logs

```shell
# Same session, same file, six writes. The file is re-read every time --
# EFFORT_PROBE changes with each write. CLAUDE_EFFORT never moves off the
# level the session launched at.

$ cat .claude/settings.local.json # present at launch
{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "high"}}

$ printf '%s\n' '{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "low"}}' > .claude/settings.local.json
# next request:
$ echo "$CLAUDE_EFFORT"
high

$ printf '%s\n' '{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "low", "EFFORT_PROBE": "probe-1"}}' > .claude/settings.local.json
# next request:
$ echo "EFFORT_PROBE=[$EFFORT_PROBE] CLAUDE_EFFORT=[$CLAUDE_EFFORT]"
EFFORT_PROBE=[probe-1] CLAUDE_EFFORT=[high]

$ printf '%s\n' '{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "xhigh", "EFFORT_PROBE": "probe-2"}}' > .claude/settings.local.json
# next request:
EFFORT_PROBE=[probe-2] CLAUDE_EFFORT=[high]

$ printf '%s\n' '{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "unset", "EFFORT_PROBE": "probe-3"}}' > .claude/settings.local.json
# next request:
EFFORT_PROBE=[probe-3] CLAUDE_EFFORT=[high]

$ printf '%s\n' '{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "low", "EFFORT_PROBE": "probe-4"}}' > .claude/settings.local.json
# next request:
EFFORT_PROBE=[probe-4] CLAUDE_EFFORT=[high]
```

The transcript agrees with `$CLAUDE_EFFORT` throughout — every assistant record in that session carries `"effort":"high"`.

The unpinned case, for contrast. Different session, same machine, same version, 39 minutes earlier, in a worktree with no `.claude/settings.local.json` at launch. Two successive writes, both applied:

```shell
20:40:46 effort -> high # launch
20:40:51 effort -> medium # a UserPromptSubmit hook wrote "medium"
20:40:51 wrote '{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "high"}}'
20:41:09 effort -> high
```

### Steps to Reproduce

The `CLAUDE_EFFORT` reads below are the shell's, in a Bash tool call. The
transcript's own `effort` field says the same thing and is the better witness if
you'd rather not trust the env var:

```bash
grep -o '"effort":"[a-z]*"' ~/.claude/projects//.jsonl | uniq -c
```

Unpinned session, where it works:

1. In a repo with `.claude/settings.local.json` gitignored and **absent**, start a session.
2. Write `{"env": {"CLAUDE_CODE_EFFORT_LEVEL": "low"}}` to that path.
3. In the next request, `echo "$CLAUDE_EFFORT"` → `low`. Write `high` instead, and the request after that reads `high`. Repeat as often as you like.

Pinned session, where it doesn't:

4. Leave the file in place with some level in it and start a **new** session in that directory.
5. `echo "$CLAUDE_EFFORT"` → whatever step 4's file said. Expected.
6. Write a different level to the same file. In the next request, `echo "$CLAUDE_EFFORT"` → still the launch level.
7. Add a second key to the same write, say `"EFFORT_PROBE": "probe-1"`. The probe shows up in the next request's environment, so the file was read and only the effort key was discarded.
8. Write `"CLAUDE_CODE_EFFORT_LEVEL": "unset"`, then write a real level after it. Both are ignored.

### Claude Model

Opus

### Is this a regression?

I don't know

### Last Working Version

N/A

### Claude Code Version

2.1.270 (Claude Code)

### Platform

Anthropic API

### Operating System

macOS

### Terminal/Shell

Other

### Additional Information

Sessions here are started from the Claude desktop app's Code tab, most of them in git worktrees. Shell is bash 5.3 via `CLAUDE_CODE_SHELL`. Nothing exports `CLAUDE_CODE_EFFORT_LEVEL` — it is not in the `claude` process environment (`ps eww`), only in the environment handed to tool calls.

Across 213 local sessions since 2026-09-06, in-session writes of the key split cleanly by whether the directory was pinned at launch:

| repository | pinned at launch | writes that took effect |
|---|---|---|
| `claude-skills` | yes | 0 of 10 |
| `claude-ground-truth` | yes | 0 of 7 |
| `github-actions-gateway` | no | 5 of 6 |
| `claude-bouncer` | no | 2 of 3 |

The sharpest case is one worktree of a fifth repo: a session on 2026-09-07, before the file existed there, changed level ten times; a session on 2026-09-17 in the same directory, with the file now present, ran fixed at `medium` and ignored its own write.

On the version gap — I'm on 2.1.270, current is 2.1.274. Of those four releases only 2.1.274 touches effort at all, adding an `effort` attribute to the `claude_code.llm_request` OTel span and fixing `claude agents` dropping `--effort` across an auto-update relaunch. Neither is resolution, so I don't expect this to be fixed, but I haven't retested on 2.1.274.

Two things I could not test, both flagged rather than guessed at:

- Whether the MCP `set_session_effort` tool overrides a launch-time pin. It refuses for the calling session, so I had no way to try it on myself.
- What writes `.claude/settings.local.json` into a newly created worktree. The file's ctime predates the directory holding it, so something copies it in rather than writing it there, and the level it holds matches the parent checkout's. That is how a single pinned repo root ends up pinning every worktree cut from it, which is what makes this spread.

One side note that may be useful for #93735, which reports the `effortLevel` **key** in project settings never being applied in desktop sessions: the `env` route here *is* applied, at launch. In a repo pinned to `medium` that way, sessions ran `medium` for every request while the app's own record of the session said `high`. So the two routes do not appear to be resolved by the same code path.

### Related

- #93735 — project-scope `effortLevel` never applied in desktop-app sessions; adjacent, different key, see the note above
- #90419 — in-session effort changes not persisting with `CLAUDE_CODE_EFFORT_LEVEL` set; same symptom from a shell-exported variable, which is a different cause and arguably correct
- #87326 — Remote Control ignores `settings.json` `effortLevel`
- #90229 — statusline effort not updating mid-session

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reproducing the pinned and unpinned cases with .claude/settings.local.json and the Bash tool call, checking CLAUDE_CODE_EFFORT_LEVEL on the next request. Compare the resulting environment with the session transcript JSONL mentioned in the report. Done means the chosen behavior is consistent for launch-time pins and later writes, including unset, or that rejected writes are reported visibly.

Written by the indexing model from the issue text.

Assessment

Tech stack
bash
Domain
cli, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
50/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.