Stop Docker disk growth, and fix the Claude Code settings that never worked

Two independent sets of fixes.

## Disk: stop the growth, no UI this round

The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.

* **Scrub the writable layer before every commit** (`docker/container.rs`,
  `SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
  are still free to drop is before the commit that captures them. Measured on
  one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
  `/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
  no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
  lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
  leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
  `/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
  and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
  mounts. Failure is a log line — a scrub must never block a snapshot.

* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
  anywhere, so containers ran on the daemon's unbounded `json-file` default.
  Deliberately *not* wired into `container_needs_recreation`: participating
  would recreate every project once, and a recreation costs a commit, which is
  the thing being fixed. Picked up on the next natural recreation.

* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
  no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
  pair provably could not match one — ~11.9 GB observed stranded. Stamping
  `triple-c.managed=true` is the whole fix; the sweep needed no change.
  `create_container` writes the new `triple-c.base` key explicitly empty, or
  Docker's label inheritance plus `docker commit` would make every snapshot
  claim to be a base image. `force: false` stays, and now says why.

* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
  (a probe pins an image the unforced sweep then refuses), pins second, sweep
  last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
  the report away — `reclaimed_bytes`, `failed` and `unavailable` included.

* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
  migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
  age-reaped by scanning the tag pattern rather than trusting the state file —
  `migration_store::load` reports an unparseable record as absent, which
  stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
  record aside so `has_record` is trustworthy. A pin whose migration is still
  awaiting confirmation is never reaped at any age. The probe container's
  removal was a plain statement after an await, so a dropped future (an app quit
  mid-migration) leaked a container pinning a multi-gigabyte image; it is a
  `Drop` guard now, with `reap_probe_containers` for the case where the process
  itself dies.

* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
  directory, and the task runner appended uncapped `claude -p` output.

* **Fix the delete copy.** It said "the container, config volume, and stored
  credentials"; it removes *both* volumes and the snapshot image.

No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.

## Claude Code settings: two invented keys, one inverted default, one sticky bug

Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.

* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
  was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
  does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
  `CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
  could never turn the recap *off*. The field is renamed to
  `session_recap_disabled` rather than reused: reusing the name with the
  opposite meaning would have read every stored `enable_session_recap: false` —
  which is every project that never touched the control — as "the user turned
  this off".
* **The stickiness, which is the important one.** Keys were emitted only when
  non-default, and the entrypoint *merges* into a settings.json on a persisted
  volume, so switching a setting off omitted its key, the merge preserved the
  stale on-value, and the setting stayed on until a destructive Reset. The fix
  already existed in the same file — the sandbox block is emitted
  unconditionally for exactly this reason — and is now applied to all five keys.
  A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
  `awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
  because a stand-in value is not neutral: `tui: "default"` pins the classic
  renderer where unset lets Claude Code choose, and `viewMode: "default"`
  overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
  bakes container env into the snapshot image, so a `=1` written once rode it
  forever. All four are now emitted on every create, extracted into
  `claude_code_env_vars` and unit tested. Two use an empty value for "off"
  rather than `0`, because they outrank a setting the user can change from
  inside their own container and Triple-C's default must not overrule a
  `/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
  which the always-emitted key makes both necessary and possible.

`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.

Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 08:35:10 -07:00
co-authored by Claude Opus 5
parent 75cace7dde
commit dd2894cc60
19 changed files with 1469 additions and 112 deletions
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
import type { ClaudeCodeSettings } from "../../lib/types";
function renderEditor(settings: ClaudeCodeSettings | null) {
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<ClaudeCodeSettingsEditor settings={settings} disabled={false} onSave={onSave} />,
);
return onSave;
}
describe("ClaudeCodeSettingsEditor", () => {
it("shows the two default-on settings as on for a project that never touched them", () => {
// Claude Code's session recap and fullscreen auto-scroll are both on by
// default, and the fields behind them store the *disabled* sense. A toggle
// rendered straight from the field would tell every existing user their
// recap is off.
renderEditor(null);
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
});
it("stores the disabled sense when an inverted toggle is switched off", () => {
const onSave = renderEditor(null);
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ session_recap_disabled: true }),
);
});
it("collapses back to null once every setting is at its default again", () => {
// `null` is what tells the backend this project adds nothing over the
// global settings, so the round trip has to land exactly back on it.
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenCalledWith(null);
});
it("offers the classic renderer as a choice distinct from automatic", () => {
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
// different, and previously unreachable, instruction.
const onSave = renderEditor(null);
const tui = screen.getByLabelText("TUI mode");
expect(
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
).toEqual(["", "default", "fullscreen"]);
fireEvent.change(tui, { target: { value: "default" } });
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
});
it("offers every effort level Claude Code accepts", () => {
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh"]);
});
});
@@ -16,7 +16,7 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
auto_scroll_disabled: false,
focus_mode: false,
show_thinking_summaries: false,
enable_session_recap: false,
session_recap_disabled: false,
env_scrub: false,
prompt_caching_1h: false,
};
@@ -28,16 +28,25 @@ function isAllDefaults(s: ClaudeCodeSettings): boolean {
s.auto_scroll_disabled === false &&
s.focus_mode === false &&
s.show_thinking_summaries === false &&
s.enable_session_recap === false &&
s.session_recap_disabled === false &&
s.env_scrub === false &&
s.prompt_caching_1h === false
);
}
/**
* Two of Claude Code's settings are **on by default**, so the field behind them
* stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`)
* — that is what makes an untouched project mean "leave Claude Code alone"
* rather than "the user turned this off". `invert` is what lets those still
* read as an ordinary on/off switch here: the toggle shows the feature's state,
* the field stores the deviation from the default.
*/
const BOOLEAN_FIELDS: {
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
label: string;
hint: string;
invert?: boolean;
}[] = [
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
{
@@ -46,14 +55,16 @@ const BOOLEAN_FIELDS: {
hint: "Shows Claude's thinking process as summaries.",
},
{
key: "enable_session_recap",
key: "session_recap_disabled",
label: "Session recap",
hint: "Provides context when returning to a session.",
hint: "Shows a one-line recap when you return to the terminal after a few minutes away.",
invert: true,
},
{
key: "auto_scroll_disabled",
label: "Auto-scroll disabled",
hint: "Disables auto-scroll when in fullscreen TUI mode.",
label: "Auto-scroll",
hint: "Follows new output to the bottom in fullscreen rendering.",
invert: true,
},
{
key: "env_scrub",
@@ -95,9 +106,16 @@ export default function ClaudeCodeSettingsEditor({
</p>
)}
{/*
Three states, not two. Leaving `tui` unset is what lets Claude Code pick
the renderer for itself, which is not the same as pinning the classic
one — and the key is now always written (or explicitly deleted), so
"Automatic" has to be selectable rather than merely being what you get
when nothing is emitted.
*/}
<SwitchRow
label="TUI mode"
hint="Enables flicker-free alt-screen rendering."
hint="Classic renders in your terminal's scrollback; fullscreen is the flicker-free alt-screen."
control={
<select
value={local.tui_mode ?? ""}
@@ -106,7 +124,8 @@ export default function ClaudeCodeSettingsEditor({
disabled={disabled}
className={selectClass}
>
<option value="">Default</option>
<option value="">Automatic</option>
<option value="default">Classic</option>
<option value="fullscreen">Fullscreen</option>
</select>
}
@@ -127,11 +146,12 @@ export default function ClaudeCodeSettingsEditor({
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Extra high</option>
</select>
}
/>
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => (
<SwitchRow
key={key}
label={label}
@@ -139,9 +159,11 @@ export default function ClaudeCodeSettingsEditor({
control={
<Toggle
label={label}
checked={local[key]}
checked={invert ? !local[key] : local[key]}
disabled={disabled}
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
onChange={(v) =>
apply({ [key]: invert ? !v : v } as Partial<ClaudeCodeSettings>)
}
/>
}
/>
@@ -28,10 +28,24 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
</>
}
>
{/*
Everything remove_project() destroys, named. It removes the container,
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
the triple-c-snapshot-{id} image and the project's keychain secrets — so
an accurate warning has to reach past "the config volume". The last
sentence is the reassuring half and matters just as much: project folders
are bind mounts from the host and nothing here touches them.
*/}
<p className="text-[13px] text-[var(--text-secondary)]">
Are you sure you want to remove{" "}
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
delete the container, config volume, and stored credentials.
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
its container, both of its volumes and its saved container image so the home
directory, the Claude login and config, installed skills, session transcripts,
scheduled tasks and any stored credentials all go with it.
</p>
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
Your project folders on this machine are mounted in, not copied, and are left
untouched.
</p>
</Modal>
);
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
</div>
{task && (
/*
An edit is `add` then `remove` (see `update_scheduled_task`), and
`triple-c-scheduler`'s remove now reaps the task's log directory —
so on a current container the old logs are gone, not merely filed
under the old id, which is what this used to promise.
It is deliberately not stated as a certainty. `/usr/local/bin` only
changes on base-image migration or Reset, so a project still running
an older base image carries the older scheduler, whose remove leaves
the log directory behind. "Assume they go with it" is true in both
worlds and spares the user a paragraph about which one they are in.
*/
<p className="text-xs text-[var(--text-secondary)]">
The scheduler has no edit command, so saving re-creates this task under a new id and
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
the old id.
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
with it.
</p>
)}
+8 -1
View File
@@ -134,12 +134,19 @@ export interface OpenAiCompatibleConfig {
}
export interface ClaudeCodeSettings {
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
tui_mode: string | null;
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
effort: string | null;
auto_scroll_disabled: boolean;
/** Written as `viewMode: "focus"`. */
focus_mode: boolean;
show_thinking_summaries: boolean;
enable_session_recap: boolean;
/**
* Turns the session recap **off**. Held in the disabled sense because Claude
* Code's recap is on by default — see the Rust doc on `ClaudeCodeSettings`.
*/
session_recap_disabled: boolean;
env_scrub: boolean;
prompt_caching_1h: boolean;
}