diff --git a/src/panopticon/container/cli/claude.py b/src/panopticon/container/cli/claude.py index ddc9b239..67da2600 100644 --- a/src/panopticon/container/cli/claude.py +++ b/src/panopticon/container/cli/claude.py @@ -19,7 +19,7 @@ from panopticon.container.cli.base import AgentCLI, _Client from panopticon.container.config import update_json_config -from panopticon.container.hooks import write_settings +from panopticon.container.hooks import THEME, write_settings from panopticon.container.skills import write_commands, write_operation_commands from panopticon.core.models import Skill @@ -97,6 +97,11 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path: - ``hasAcknowledgedCostThreshold`` — cost-acknowledgment dialog shown when authenticating via ``ANTHROPIC_API_KEY`` (not shown for OAuth tokens). + It also seeds :data:`~panopticon.container.hooks.THEME` here, claude's *legacy* home for the + setting — the settings file :meth:`write_settings` renders is the modern one, and seeding + both covers either resolution order (the values agree). Seeded, not enforced, for the same + reason: an operator's ``/theme`` inside the container must survive a respawn. + Merge-in-place so we don't clobber config claude writes itself, and idempotent. The path encoding is undocumented internals — a safe degradation if it ever drifts is that the dialog reappears, which only matters in an (already attended) interactive re-attach. @@ -105,6 +110,7 @@ def trust_workspace(self, config_dir: Path, cwd: Path) -> Path: with update_json_config(config) as data: data["hasCompletedOnboarding"] = True data["hasAcknowledgedCostThreshold"] = True + data.setdefault("theme", THEME) projects = data.setdefault("projects", {}) projects.setdefault(str(cwd), {})["hasTrustDialogAccepted"] = True return config diff --git a/src/panopticon/container/hooks.py b/src/panopticon/container/hooks.py index 3a32cfd3..da1a7a59 100644 --- a/src/panopticon/container/hooks.py +++ b/src/panopticon/container/hooks.py @@ -10,6 +10,9 @@ *agent* once it's answered. ``AskUserQuestion`` is a mid-turn tool call — it never fires ``Stop`` — so without this the turn would wrongly read *agent* the whole time the question is pending. +It also seeds the two *non-hook* settings a fresh container needs: the Bypass Permissions +pre-accept (see :func:`settings`) and the starting :data:`THEME`. + claude-specific (`.claude/settings.json`); M3 revisits for other CLIs. Pure — the callback the hooks invoke is :mod:`panopticon.container.hook`. `:blocked:` is preserved by construction: the callback only sets the turn, never the block. @@ -25,6 +28,14 @@ #: The command claude runs for each hook event (sets the turn via the task service). HOOK_COMMAND = "python -m panopticon.container.hook" +#: The theme a fresh container's claude starts in. ``auto`` follows the terminal's background, so a +#: pane an operator attaches over `tmux`/`ssh` is readable on a light *or* dark terminal — claude's +#: own default is a fixed ``dark``. claude has no ``--theme`` flag, so this is config-file state: +#: ``theme`` is a *user setting*, i.e. it lives in the settings file :func:`write_settings` writes +#: (:meth:`~panopticon.container.cli.claude.ClaudeAgentCLI.trust_workspace` seeds the same value +#: into claude's legacy global config for older builds that read it from there). +THEME = "auto" + def settings() -> dict[str, Any]: """The `.claude/settings.json` we seed: the turn-flip hooks **and** a pre-accept of Bypass @@ -61,8 +72,15 @@ def run(actor: str, event: str | None = None, *, matcher: str | None = None) -> def write_settings(home: Path) -> Path: - """Merge the turn-flip hooks into ``/.claude/settings.json``; return the path.""" + """Merge the turn-flip hooks into ``/.claude/settings.json``; return the path. + + :data:`THEME` is seeded rather than enforced: the config dir is a **per-task volume** that + outlives a respawn, and the ask is what a claude instance *starts* in — so a deliberate + ``/theme`` change made inside the container survives every later launch, while the hooks and the + permission pre-accept (which the container can't work without) are re-applied each time. + """ path = home / ".claude" / "settings.json" with update_json_config(path) as data: data.update(settings()) + data.setdefault("theme", THEME) return path diff --git a/tests/container/test_claude.py b/tests/container/test_claude.py index 0bd8bbbc..1298d2d8 100644 --- a/tests/container/test_claude.py +++ b/tests/container/test_claude.py @@ -10,6 +10,7 @@ import pytest from panopticon.container.cli.claude import INTERRUPT_PROMPT, ClaudeAgentCLI +from panopticon.container.hooks import THEME class _FakeClient: @@ -199,6 +200,18 @@ def test_trust_workspace_seeds_acceptance_for_a_fresh_config(tmp_path: Path) -> assert data["projects"]["/workspace"]["hasTrustDialogAccepted"] is True assert data["hasCompletedOnboarding"] is True assert data["hasAcknowledgedCostThreshold"] is True # suppresses the API-key cost dialog + assert data["theme"] == THEME # claude's legacy home for the starting theme + + +def test_trust_workspace_keeps_a_theme_the_container_already_chose(tmp_path: Path) -> None: + # Seeded, not enforced — a `/theme` run inside the container survives the next launch. + config_dir = tmp_path / ".claude" + config_dir.mkdir() + (config_dir / ClaudeAgentCLI.CONFIG_FILE).write_text(json.dumps({"theme": "light"})) + ClaudeAgentCLI().trust_workspace(config_dir, Path("/workspace")) + data = json.loads((config_dir / ClaudeAgentCLI.CONFIG_FILE).read_text()) + assert data["theme"] == "light" + assert data["hasCompletedOnboarding"] is True # the pre-accepts are still applied def test_trust_workspace_merges_and_is_idempotent(tmp_path: Path) -> None: diff --git a/tests/container/test_hooks.py b/tests/container/test_hooks.py index 43d0ffce..509cb94c 100644 --- a/tests/container/test_hooks.py +++ b/tests/container/test_hooks.py @@ -9,7 +9,7 @@ import pytest from panopticon.container import hook -from panopticon.container.hooks import settings, write_settings +from panopticon.container.hooks import THEME, settings, write_settings def test_settings_wire_stop_to_user_and_prompt_to_agent() -> None: @@ -41,6 +41,28 @@ def test_write_settings_writes_claude_settings(tmp_path: Path) -> None: assert "Stop" in json.loads(path.read_text())["hooks"] +def test_write_settings_seeds_the_starting_theme(tmp_path: Path) -> None: + # claude has no --theme flag and defaults to a fixed dark; `auto` follows the terminal's + # background, so an attached pane is readable on a light or dark terminal alike. + path = write_settings(tmp_path) + assert THEME == "auto" + assert json.loads(path.read_text())["theme"] == "auto" + + +def test_write_settings_keeps_a_theme_the_container_already_chose(tmp_path: Path) -> None: + # Seeded, not enforced: the config dir outlives a respawn, so a `/theme` run inside the + # container must survive the next launch — while the hooks are still re-applied. + settings_path = tmp_path / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text('{"theme": "dark"}') + + write_settings(tmp_path) + + data = json.loads(settings_path.read_text()) + assert data["theme"] == "dark" # the operator's choice wins + assert "Stop" in data["hooks"] + + def test_write_settings_merges_without_clobbering_existing_keys(tmp_path: Path) -> None: # Routed through the read-merge-write helper: any unrelated settings already on disk survive. settings_path = tmp_path / ".claude" / "settings.json"