diff --git a/README.md b/README.md
index 61359a2..c5fc3b2 100644
--- a/README.md
+++ b/README.md
@@ -505,7 +505,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
-| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
+| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
### Frontend — settings, terminal and hooks
diff --git a/ROADMAP.md b/ROADMAP.md
index 3b1a8c2..50e8832 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -26,20 +26,35 @@ scheduler, and the fleet view across many projects.
## Current coverage (v0.3.0)
-Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
+Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
| Key | Surfaced as |
|---|---|
-| `tui` | TUI Mode select (`fullscreen`) |
-| `effort` | Effort Level select (`low`/`medium`/`high`) |
-| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
-| `focusMode` | Focus Mode toggle |
-| `showThinkingSummaries` | Thinking Summaries toggle |
+| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
+| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
+| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
+| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
+| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
+| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
+Every one of those keys is emitted on **every** start, with a JSON `null` standing for
+"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
+merges into it, so a key merely omitted when its control goes off left the previous
+on-value in place forever.
+
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
-`CLAUDE_CODE_*` vars via the Env Vars modal.
+`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
+create *including* their off value, because `docker commit` bakes a container's env into
+the snapshot image: a value written once would otherwise ride that snapshot into every
+future container. That also makes them Triple-C's to own, so all four are reserved names
+— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
+`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
+the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
+in-container `/config` toggle, so turning the control off sends `0` while leaving it on
+sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
+choice it never asked about.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
diff --git a/app/src-tauri/src/commands/inspect_commands.rs b/app/src-tauri/src/commands/inspect_commands.rs
index 68749f3..4bd4cf9 100644
--- a/app/src-tauri/src/commands/inspect_commands.rs
+++ b/app/src-tauri/src/commands/inspect_commands.rs
@@ -1200,9 +1200,14 @@ pub async fn add_scheduled_task(
/// * **In that order**, so a rejected `add` leaves the original untouched
/// rather than deleting a prompt the user cannot get back. The cost is a
/// sub-second window in which both tasks are in the crontab.
-/// * The task therefore gets a **new id**. Its old log directory
-/// (`~/.claude/scheduler/logs//`) stays behind under the old id; the
-/// UI warns about this before saving.
+/// * The task therefore gets a **new id**, and its old log directory
+/// (`~/.claude/scheduler/logs//`) goes with the removal — the
+/// scheduler reaps a task's logs when the task stops existing, because
+/// nothing can name that id again afterwards. The UI warns before saving.
+/// (A project still running an older base image carries the older
+/// `/usr/local/bin/triple-c-scheduler`, which left the directory behind;
+/// `/usr/local/bin` only changes on a base-image migration or a Reset. The
+/// copy is deliberately written for the case that loses data.)
/// * `enabled` is carried over explicitly, because `add` always creates an
/// enabled task and silently re-enabling a task the user had switched off
/// would schedule a run they did not ask for.
diff --git a/app/src-tauri/src/commands/migration_commands.rs b/app/src-tauri/src/commands/migration_commands.rs
index f43e348..907b0ed 100644
--- a/app/src-tauri/src/commands/migration_commands.rs
+++ b/app/src-tauri/src/commands/migration_commands.rs
@@ -863,7 +863,7 @@ pub async fn confirm_migration(
// waiting for the project's next recreation would leave it lying around
// indefinitely.
tauri::async_runtime::spawn(async {
- crate::docker::sweep_orphaned_snapshots().await;
+ crate::docker::sweep_orphaned_snapshots_logged("after migration confirmed").await;
});
Ok(())
@@ -957,6 +957,16 @@ pub async fn rollback_migration(
let _ = mig::untag_image(&rollback_ref).await;
migration_store::clear_staging(&project_id)?;
migration_store::clear(&project_id)?;
+
+ // Retagging above moved `:latest` off the *migrated* snapshot, and the
+ // container that was built from it was removed a few lines up — so a
+ // multi-gigabyte image is sitting there untagged and unreferenced with
+ // nothing else in the app that would ever look at it again. The confirm
+ // path sweeps for exactly this reason; rolling back orphans just as much
+ // and did not.
+ tauri::async_runtime::spawn(async {
+ crate::docker::sweep_orphaned_snapshots_logged("after migration rollback").await;
+ });
emit_progress(
&app_handle,
&project_id,
diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs
index a158925..09030ef 100644
--- a/app/src-tauri/src/commands/project_commands.rs
+++ b/app/src-tauri/src/commands/project_commands.rs
@@ -459,7 +459,7 @@ pub async fn start_project_container(
// just this one, so recreations that happened before the sweep
// existed are cleaned up too.
tauri::async_runtime::spawn(async {
- docker::sweep_orphaned_snapshots().await;
+ docker::sweep_orphaned_snapshots_logged("after recreation").await;
});
new_id
diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs
index f4ca0c0..0799b44 100644
--- a/app/src-tauri/src/docker/container.rs
+++ b/app/src-tauri/src/docker/container.rs
@@ -218,6 +218,12 @@ pub const SECRET_ENV_KEYS: &[&str] = &[
/// which is what keeps the sweep away from the user's own images.
const LABEL_MANAGED: &str = "triple-c.managed";
+/// Marks the image built from `container/Dockerfile` itself, as opposed to a
+/// project snapshot committed from a container. Only ever `"true"` on a base
+/// image; `create_container` writes it explicitly empty so an inherited value
+/// cannot travel onto a snapshot. See the `LABEL` block in the Dockerfile.
+const LABEL_BASE: &str = "triple-c.base";
+
const RESERVED_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
/// Exact env var names Triple-C manages itself. Not covered by
@@ -235,6 +241,15 @@ const RESERVED_ENV_EXACT: &[&str] = &[
"MISSION_CONTROL_ENABLED",
"VPN_SUPPORT_ENABLED",
"TRIPLE_C_PERMISSION_MODE",
+ // The four env vars the Claude Code settings editor drives. Reserved for
+ // the `VPN_SUPPORT_ENABLED` reason: each is now written on every create,
+ // including its off value, so a hand-set custom var of the same name would
+ // either be overridden without explanation or override the setting behind
+ // the UI's back, depending on which one Docker kept.
+ "CLAUDE_CODE_NO_FLICKER",
+ "CLAUDE_CODE_ENABLE_AWAY_SUMMARY",
+ "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB",
+ "ENABLE_PROMPT_CACHING_1H",
CLAUDE_OAUTH_TOKEN_ENV,
// The model-alias vars are already covered by the `ANTHROPIC_` prefix
// above; they are listed explicitly so that a future narrowing of the
@@ -633,7 +648,7 @@ fn merge_claude_code_settings(
auto_scroll_disabled: if p.auto_scroll_disabled { true } else { g.auto_scroll_disabled },
focus_mode: if p.focus_mode { true } else { g.focus_mode },
show_thinking_summaries: if p.show_thinking_summaries { true } else { g.show_thinking_summaries },
- enable_session_recap: if p.enable_session_recap { true } else { g.enable_session_recap },
+ session_recap_disabled: if p.session_recap_disabled { true } else { g.session_recap_disabled },
env_scrub: if p.env_scrub { true } else { g.env_scrub },
prompt_caching_1h: if p.prompt_caching_1h { true } else { g.prompt_caching_1h },
})
@@ -660,7 +675,7 @@ fn compute_claude_code_settings_fingerprint(
format!("{}", s.auto_scroll_disabled),
format!("{}", s.focus_mode),
format!("{}", s.show_thinking_summaries),
- format!("{}", s.enable_session_recap),
+ format!("{}", s.session_recap_disabled),
format!("{}", s.env_scrub),
format!("{}", s.prompt_caching_1h),
];
@@ -674,34 +689,164 @@ fn compute_claude_code_settings_fingerprint(
}
}
-/// Build the settings.json content for Claude Code.
-/// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
-/// Always emits a `sandbox.enabled` key reflecting the current per-project
-/// toggle so that flipping it off in triple-c overrides any prior on-state
-/// stored in the persisted settings.json (which lives in a named volume).
+/// The four Claude Code env vars the settings editor drives, as `KEY=VALUE`.
+///
+/// **All four are emitted on every create, including their off value.** This is
+/// the `MANAGED_AUTH_KEYS` rule: `docker commit` bakes a container's env into
+/// the snapshot image, and the next container inherits anything the create does
+/// not override. A `=1` written once would ride that snapshot into every future
+/// container and make the switch impossible to turn back off — the same
+/// stickiness [`build_claude_code_settings_json`] fixes on the settings.json
+/// side, in a place where it is even less visible.
+///
+/// Two of them use an **empty** value for "off", and the distinction matters:
+///
+/// * `CLAUDE_CODE_NO_FLICKER` documents `1` as fullscreen-on and `0` as
+/// fullscreen-*off*, and it overrides the `tui` setting. `0` is therefore not
+/// neutral — it would silently pin every project that has expressed no
+/// preference to the classic renderer, when an unset `tui` is supposed to let
+/// Claude Code choose. Empty is neither value, so it reads as unset while
+/// still overriding a baked `1`.
+/// * `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` outranks both `awaySummaryEnabled` and
+/// the in-container `/config` toggle. `0` is exactly right for "the user
+/// turned the recap off in Triple-C", but a blanket `1` for the default state
+/// would force the recap back on for someone who had turned it off with
+/// `/config` inside their own container. Triple-C's default must not overrule
+/// a choice it never asked about.
+///
+/// The other two are documented as "set to `1` to …" with no meaning attached
+/// to `0`, so `0` is unambiguously neutral and is stated outright.
+fn claude_code_env_vars(settings: Option<&ClaudeCodeSettings>) -> Vec {
+ let owned;
+ let s = match settings {
+ Some(s) => s,
+ None => {
+ owned = ClaudeCodeSettings::default();
+ &owned
+ }
+ };
+
+ vec![
+ format!(
+ "CLAUDE_CODE_NO_FLICKER={}",
+ match s.tui_mode.as_deref() {
+ Some("fullscreen") => "1",
+ Some("default") => "0",
+ _ => "",
+ }
+ ),
+ format!(
+ "CLAUDE_CODE_ENABLE_AWAY_SUMMARY={}",
+ if s.session_recap_disabled { "0" } else { "" }
+ ),
+ format!(
+ "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB={}",
+ if s.env_scrub { "1" } else { "0" }
+ ),
+ format!(
+ "ENABLE_PROMPT_CACHING_1H={}",
+ if s.prompt_caching_1h { "1" } else { "0" }
+ ),
+ ]
+}
+
+/// Build the settings.json payload for Claude Code, handed to the container as
+/// `CLAUDE_CODE_SETTINGS_JSON` and applied by `entrypoint.sh`.
+///
+/// ## Every managed key is always present, and `null` means "delete"
+///
+/// The settings file lives on `triple-c-claude-config-{projectId}`, a named
+/// volume that outlives the container, and the entrypoint *merges* into it. So
+/// a key emitted only when it is non-default can be written once and never
+/// taken back: turning the setting off simply omits the key, the merge
+/// preserves whatever was there, and the setting stays on forever. Only a
+/// destructive Reset — which also deletes the OAuth login, skills and
+/// transcripts — ever cleared it. Four of the five keys here were sticky that
+/// way; the `sandbox` block already carried the workaround and the comment
+/// explaining it, and this is the same treatment applied to the rest.
+///
+/// Two shapes of "off" are needed, because Claude Code's own defaults differ:
+///
+/// * **A boolean with a documented default** (`autoScrollEnabled` is `true`,
+/// `showThinkingSummaries` is `false`) is emitted with that neutral value.
+/// * **A key whose neutral state is *unset*** (`tui`, `effortLevel`,
+/// `viewMode`, `awaySummaryEnabled`) is emitted as JSON `null`, and the
+/// entrypoint deletes rather than merges those. Writing a stand-in value
+/// would not be neutral: an unset `tui` lets Claude Code choose the renderer
+/// (`"default"` pins the classic one), and an unset `viewMode` lets the
+/// user's own sticky `/focus` choice and `verbose` setting apply
+/// (`"default"` overrides both).
+///
+/// Returns a `String` rather than an `Option`: there is no longer any
+/// input for which this produces nothing to say.
fn build_claude_code_settings_json(
settings: Option<&ClaudeCodeSettings>,
sandbox_enabled: bool,
-) -> Option {
+) -> String {
+ let owned;
+ let s = match settings {
+ Some(s) => s,
+ // No struct at all is not "say nothing" — it is "every setting is at
+ // its default", which still has to be asserted over a stale file.
+ None => {
+ owned = ClaudeCodeSettings::default();
+ &owned
+ }
+ };
+
let mut map = serde_json::Map::new();
- if let Some(s) = settings {
- if let Some(ref tui) = s.tui_mode {
- map.insert("tui".to_string(), serde_json::json!(tui));
- }
- if let Some(ref effort) = s.effort {
- map.insert("effort".to_string(), serde_json::json!(effort));
- }
- if s.auto_scroll_disabled {
- map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
- }
+ // `null` clears; see the module doc above.
+ map.insert(
+ "tui".to_string(),
+ match s.tui_mode {
+ Some(ref tui) => serde_json::json!(tui),
+ None => serde_json::Value::Null,
+ },
+ );
+ // `effortLevel`, not `effort`. Claude Code has never read a key called
+ // `effort`, so the previous value was written and silently ignored.
+ map.insert(
+ "effortLevel".to_string(),
+ match s.effort {
+ Some(ref effort) => serde_json::json!(effort),
+ None => serde_json::Value::Null,
+ },
+ );
+ // Documented default `true`, so the neutral value is a value.
+ map.insert(
+ "autoScrollEnabled".to_string(),
+ serde_json::json!(!s.auto_scroll_disabled),
+ );
+ // Documented default `false`.
+ map.insert(
+ "showThinkingSummaries".to_string(),
+ serde_json::json!(s.show_thinking_summaries),
+ );
+ // `viewMode: "focus"` is the real setting behind what the UI calls focus
+ // mode — "collapses tool output to one-line summaries" is that key's
+ // documented behaviour. The `focusMode` key it replaces was invented and
+ // did nothing.
+ map.insert(
+ "viewMode".to_string(),
if s.focus_mode {
- map.insert("focusMode".to_string(), serde_json::json!(true));
- }
- if s.show_thinking_summaries {
- map.insert("showThinkingSummaries".to_string(), serde_json::json!(true));
- }
- }
+ serde_json::json!("focus")
+ } else {
+ serde_json::Value::Null
+ },
+ );
+ // The recap is on by default, so only the *off* case has anything to write.
+ // `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` (set unconditionally at creation) takes
+ // precedence over this key and is what actually enforces the choice; this
+ // is here so the container's settings.json does not contradict it.
+ map.insert(
+ "awaySummaryEnabled".to_string(),
+ if s.session_recap_disabled {
+ serde_json::json!(false)
+ } else {
+ serde_json::Value::Null
+ },
+ );
// Always emit `sandbox.enabled` so that toggling the per-project sandbox
// off in triple-c clears any prior on-state in the persisted
@@ -719,11 +864,7 @@ fn build_claude_code_settings_json(
};
map.insert("sandbox".to_string(), sandbox_obj);
- if map.is_empty() {
- None
- } else {
- Some(serde_json::Value::Object(map).to_string())
- }
+ serde_json::Value::Object(map).to_string()
}
pub async fn find_existing_container(project: &Project) -> Result
)}
+ {/*
+ 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.
+ */}
-
Default
+
Automatic
+
Classic
Fullscreen
}
@@ -127,11 +146,12 @@ export default function ClaudeCodeSettingsEditor({
Low
Medium
High
+
Extra high
}
/>
- {BOOLEAN_FIELDS.map(({ key, label, hint }) => (
+ {BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => (
apply({ [key]: v } as Partial)}
+ onChange={(v) =>
+ apply({ [key]: invert ? !v : v } as Partial)
+ }
/>
}
/>
diff --git a/app/src/components/projects/ConfirmRemoveModal.tsx b/app/src/components/projects/ConfirmRemoveModal.tsx
index 215c3fa..1fc53b4 100644
--- a/app/src/components/projects/ConfirmRemoveModal.tsx
+++ b/app/src/components/projects/ConfirmRemoveModal.tsx
@@ -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.
+ */}
Are you sure you want to remove{" "}
- {projectName}? This will
- delete the container, config volume, and stored credentials.
+ {projectName}? 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.
+
+
+ Your project folders on this machine are mounted in, not copied, and are left
+ untouched.
);
diff --git a/app/src/components/projects/home/TaskEditorModal.tsx b/app/src/components/projects/home/TaskEditorModal.tsx
index 7e4d34f..09a57ca 100644
--- a/app/src/components/projects/home/TaskEditorModal.tsx
+++ b/app/src/components/projects/home/TaskEditorModal.tsx
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
{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.
+ */
The scheduler has no edit command, so saving re-creates this task under a new id and
- removes {task.id}. Its previous run logs stay under
- the old id.
+ removes {task.id}. Assume its earlier run logs go
+ with it.
)}
diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts
index b4eec82..3510ecd 100644
--- a/app/src/lib/types.ts
+++ b/app/src/lib/types.ts
@@ -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;
}
diff --git a/container/Dockerfile b/container/Dockerfile
index 8b4cc41..fc81ee9 100644
--- a/container/Dockerfile
+++ b/container/Dockerfile
@@ -1,5 +1,24 @@
FROM ubuntu:24.04
+# ── Provenance labels ────────────────────────────────────────────────────────
+# Without these the base image carries no labels at all, and
+# `sweep_orphaned_snapshots` (app/src-tauri/src/docker/container.rs) filters on
+# `dangling=true` **and** `triple-c.managed=true` — so a superseded base image,
+# left untagged when a newer build claims `triple-c-sandbox:latest`, could never
+# match and was never collected. ~11.9 GB of stranded base images was measured
+# on one developer's daemon this way.
+#
+# `triple-c.managed=true` is what makes them sweepable. Note that Docker merges
+# an image's labels into the containers created from it and `docker commit`
+# copies a container's labels onto the image, so this value also arrives on
+# every container and every snapshot — which is harmless, because
+# `create_container` writes the same key explicitly anyway.
+#
+# `triple-c.base=true` marks *this* image specifically, so a base image can be
+# told apart from a project snapshot without parsing repository names.
+LABEL triple-c.managed=true
+LABEL triple-c.base=true
+
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
# Avoid interactive prompts during package install
ENV DEBIAN_FRONTEND=noninteractive
diff --git a/container/entrypoint.sh b/container/entrypoint.sh
index 2e859b2..9bc0c8e 100644
--- a/container/entrypoint.sh
+++ b/container/entrypoint.sh
@@ -405,22 +405,37 @@ install_feature_skill pia-vpn "${VPN_SUPPORT_ENABLED:-0}"
unset VPN_SUPPORT_ENABLED
# ── Claude Code settings ────────────────────────────────────────────────────
-# Merge Claude Code settings into ~/.claude/settings.json (preserves existing
-# keys). Creates the file if it doesn't exist. These control TUI mode, effort
-# level, focus mode, thinking summaries, and other CLI behavior.
+# Apply the managed Claude Code settings to ~/.claude/settings.json, keeping
+# every key the user set inside the container.
+#
+# `settings.json` lives on the persisted triple-c-claude-config-{id} volume, so
+# it outlives the container and a plain `.[0] * .[1]` merge could only ever
+# *add*. That is what made every one of these settings one-way: switching one
+# off in Triple-C omitted its key, the merge preserved the old on-value, and the
+# setting stayed on until a destructive Reset. So the payload from Rust states
+# the whole managed key set on every start, and a JSON **null** in it means
+# "delete this key" rather than "merge a null" — which is how a setting whose
+# neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
+# `awaySummaryEnabled`) is turned back off without pinning a stand-in value.
+# See `build_claude_code_settings_json` in app/src-tauri/src/docker/container.rs.
if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
SETTINGS_FILE="/home/claude/.claude/settings.json"
mkdir -p /home/claude/.claude
- if [ -f "$SETTINGS_FILE" ]; then
- # Merge: existing settings + new settings (new keys override on conflict)
- MERGED=$(jq -s '.[0] * .[1]' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
- if [ -n "$MERGED" ]; then
- printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
- else
- echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
- fi
+ # One code path for "file exists" and "file doesn't": seeding an empty
+ # object means the null-deleting merge below runs in both cases, so a fresh
+ # container never gets a settings.json with literal nulls written into it.
+ [ -f "$SETTINGS_FILE" ] || printf '{}\n' > "$SETTINGS_FILE"
+ MERGED=$(jq -s '
+ .[0] as $current
+ | .[1] as $managed
+ | ($managed | with_entries(select(.value != null))) as $set
+ | ($managed | to_entries | map(select(.value == null) | [.key])) as $clear
+ | ($current * $set) | delpaths($clear)
+ ' "$SETTINGS_FILE" <(printf '%s' "$CLAUDE_CODE_SETTINGS_JSON") 2>/dev/null)
+ if [ -n "$MERGED" ]; then
+ printf '%s\n' "$MERGED" > "$SETTINGS_FILE"
else
- printf '%s\n' "$CLAUDE_CODE_SETTINGS_JSON" > "$SETTINGS_FILE"
+ echo "entrypoint: warning — failed to merge Claude Code settings into $SETTINGS_FILE"
fi
chown claude:claude "$SETTINGS_FILE"
chmod 600 "$SETTINGS_FILE"
diff --git a/container/triple-c-scheduler b/container/triple-c-scheduler
index e75fcae..46d1e38 100644
--- a/container/triple-c-scheduler
+++ b/container/triple-c-scheduler
@@ -20,6 +20,27 @@ generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
+# Delete a task's log directory, called wherever a task stops existing.
+#
+# The task file is the only index of a task, so a log directory that outlives
+# it is unreachable — `logs --id` needs an id nothing can hand you any more —
+# and it sits on the home volume for the life of the project. The moment of
+# removal is the last point at which we still know what to delete.
+#
+# The `rm -rf` deserves paranoia, so the id is re-validated here rather than
+# trusted from the caller: the pattern rejects an empty id (which would expand
+# to $LOGS_DIR itself), anything containing `/` or `.` (which could climb out
+# of $LOGS_DIR), and a leading `-`. It matches validate_task_id() in
+# app/src-tauri/src/commands/inspect_commands.rs. Always one literal path,
+# never a glob.
+reap_task_logs() {
+ local id="${1:-}"
+ [[ "$id" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || return 0
+ local dir="${LOGS_DIR:?}/${id}"
+ [ -d "$dir" ] || return 0
+ rm -rf -- "$dir"
+}
+
# Live run state for a task: prints "pidstarted_epochlog" and returns
# 0 when the task is genuinely running, returns 1 otherwise.
#
@@ -292,6 +313,7 @@ cmd_remove() {
local name
name=$(jq -r '.name' "$task_file")
rm -f "$task_file"
+ reap_task_logs "$id"
rebuild_crontab
echo "Removed task '$name' ($id)"
}
diff --git a/container/triple-c-task-runner b/container/triple-c-task-runner
index 79ffa60..7ce583b 100644
--- a/container/triple-c-task-runner
+++ b/container/triple-c-task-runner
@@ -125,6 +125,37 @@ fi
echo "=== Exit code: $EXIT_CODE ==="
} >> "$LOG_FILE"
+# ── Cap the size of this run's log ──────────────────────────────────────────
+# `claude -p` output is unbounded — a task told to walk a large tree can emit
+# hundreds of megabytes in one run — and the pruning below counts *files*, not
+# bytes, so twenty logs of any size are twenty logs. One chatty task can
+# therefore fill the home volume, which is also where ~/.claude and the OAuth
+# credential live.
+#
+# The tail is the half worth keeping: `claude -p` writes its answer at the end,
+# and the footer just appended carries the exit code that `status` and the app
+# both grep for. So an oversize log is rewritten as a marker line plus its last
+# MAX_LOG_BYTES rather than being deleted or capped from the front. This runs
+# before the notification below so the summary is taken from the capped file.
+#
+# Best effort throughout: the run's real result is already recorded, so a
+# failure here must not change the exit status. Note that `run` may be tailing
+# this file — it has already streamed everything up to here, and nothing is
+# appended after this point, so replacing the inode is invisible to it.
+MAX_LOG_BYTES=$(( 5 * 1024 * 1024 ))
+LOG_BYTES=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)
+if [ "${LOG_BYTES:-0}" -gt "$MAX_LOG_BYTES" ]; then
+ TRUNC_FILE="${LOG_FILE}.trunc"
+ if {
+ echo "=== Log truncated: $(( LOG_BYTES - MAX_LOG_BYTES )) bytes dropped from the start (cap ${MAX_LOG_BYTES} bytes) ==="
+ tail -c "$MAX_LOG_BYTES" "$LOG_FILE"
+ } > "$TRUNC_FILE" 2>/dev/null; then
+ mv -f "$TRUNC_FILE" "$LOG_FILE" 2>/dev/null || rm -f "$TRUNC_FILE"
+ else
+ rm -f "$TRUNC_FILE"
+ fi
+fi
+
# ── Write notification ──────────────────────────────────────────────────────
mkdir -p "$NOTIFICATIONS_DIR"
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
@@ -176,6 +207,35 @@ if [ "$LOG_COUNT" -gt 20 ]; then
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
fi
+# ── Reap log dirs of tasks that no longer exist ─────────────────────────────
+# `triple-c-scheduler remove` deletes a task's log dir with the task, but a
+# one-time task deletes its own task file above, so `remove` can never be run
+# for it — nothing knows the id any more — and its directory would sit on the
+# home volume forever. This is the sweep for that case.
+#
+# Deliberately delayed rather than done in the cleanup above: the run that just
+# finished has only just written the sole record of itself, `run` and the app's
+# Automation tab may still be tailing it, and `logs --id` keeps working for a
+# task whose file is gone. So a dir is reaped only once nothing in it has been
+# touched for LOG_RETENTION_DAYS, and never while a run is publishing state for
+# that id. The sweep rides on task runs, so a container whose only task was
+# one-time keeps that one directory until something else runs.
+#
+# Same paranoia as reap_task_logs() in triple-c-scheduler: the id comes from a
+# directory name and is re-validated before it is used to build an `rm -rf`
+# path, so no empty or path-bearing name can reach beyond $LOGS_DIR.
+LOG_RETENTION_DAYS=7
+for ORPHAN_DIR in "$LOGS_DIR"/*/; do
+ [ -d "$ORPHAN_DIR" ] || continue
+ ORPHAN_ID=$(basename "$ORPHAN_DIR")
+ [[ "$ORPHAN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]] || continue
+ [ -f "${TASKS_DIR}/${ORPHAN_ID}.json" ] && continue
+ [ -f "${RUNNING_DIR}/${ORPHAN_ID}.json" ] && continue
+ # Anything modified inside the window keeps the whole directory.
+ [ -n "$(find "$ORPHAN_DIR" -mmin "-$(( LOG_RETENTION_DAYS * 1440 ))" -print -quit 2>/dev/null)" ] && continue
+ rm -rf -- "${LOGS_DIR:?}/${ORPHAN_ID}"
+done
+
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
if [ "$NOTIFY_COUNT" -gt 50 ]; then