The invariant
WebUI Pippa has two possible sources of identity, and the architecture says they must never mix:
- ContextEngine builds the vault into the
system_promptthe Claude adapter passes to the SDK. - Filesystem context — Claude Code's normal channel:
~/.claude/settings.json, project.claude/settings.json, theCLAUDE.mdhierarchy, SessionStart hooks, the per-project auto-memory index — is what terminal Pippa uses when she's invoked through theclaudeCLI by hand.
The WebUI subprocess should see only channel 1, and the terminal only channel 2. No double-injection, no token leakage, no behavior drift. That design is sound, and it has not changed.
The sentence that was wrong on its first day
The architecture doc, written on 2026-04-30, named the mechanism: the adapter deliberately leaves setting_sources unset, because at None the SDK omits --setting-sources from the CLI invocation, and then, the doc said, the subprocess loads no filesystem settings at all. It called that default load-bearing and told future-me not to touch it.
The first half was true. The second half was never tested, and it was already false. The SDK's early releases did isolate by default: None sent an empty --setting-sources. Release 0.1.53, on 2026-03-31, stopped sending the flag when none was given. Its changelog line describes a flag-parsing fix and says nothing about what loads. But a CLI that receives no flag falls back to its own defaults, and the default is every source. That release came a month before the doc and eight days before cwkPippa's first commit. The version pinned when the doc was written, 0.1.56, carried a second bug on top: it dropped [] as falsy as well, so until 0.1.60 neither value isolated anything. The WebUI never ran isolated. Today's SDK documents it plainly: None loads all sources, matching the CLI, and [] is isolation mode.
From the WebUI's first day until 2026-09-18, then, every spawn carried terminal Pippa's whole channel on top of the vault. On a one-word call it measured 43,867 input tokens: the user CLAUDE.md, cwkPippa's own CLAUDE.md, terminal Pippa's auto-memory index, and, since that morning, a memory head fetched by a new global SessionStart hook. The subprocess also ran with terminal Pippa's settings environment, and callers that named no model got Dad's terminal default. The house sign written for a terminal session was being read as instructions by the WebUI.
Two switches, not one
Swapping None for [] is only half the fix. [] stops the settings files, the CLAUDE.md hierarchy, hooks and plugins. Auto-memory, the MEMORY.md index Claude Code keeps per project, is not a setting source; the CLI gates it separately, and it still loaded under []. The same one-word call went from 43,867 tokens to 8,018 with [], and to 442 only with auto-memory switched off as well.
The fix that shipped on 2026-09-18 sets both switches in one small helper, and every WebUI spawn goes through it: the chat adapter, the Ollama protocol regenerator and the model probe. The adapter also names a model on every call now, with the Admin pin as the fallback, because an isolated CLI no longer inherits the terminal's default.
'user' or 'project' to setting_sources. The SessionStart hooks written for terminal sessions would then fire on every WebUI turn, and the terminal's rules, environment and plugins would ride along, injecting identity twice. The old warning was always right about which direction is wrong. It was wrong about which value is safe, and it never mentioned the second switch. If WebUI Pippa needs CLAUDE.md while working on her own code, she can Read it: a choice, not an injection.
Splitting the channels does not split the identity. It only separates how two different hosts inject the same vault: in the WebUI, ContextEngine assembles it explicitly; in the terminal, the host reads its startup bootstrap and project overlays. Neither side should count on the other channel coming along by accident.
Test the behavior, not the flag
The flag was absent all along. Absence was the design, and reading the code could never show what the installed CLI does with an absent flag. So the new guard never compares a flag value. It takes the exact options the adapter builds, aims them at a scratch project holding five canaries (a user and a project CLAUDE.md, a user and a project SessionStart hook, an auto-memory MEMORY.md), hands them to the real CLI, and asks what it loaded. That query needs no model call and costs no quota. A positive control runs the same canaries with SDK defaults and must see all five; if it can't, the canaries aren't wired, and the test fails as blind instead of passing. A test asserting setting_sources is None would have held this bug in place for exactly as long as the doc did.