Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/panopticon/container/cli/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion src/panopticon/container/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 ``<home>/.claude/settings.json``; return the path."""
"""Merge the turn-flip hooks into ``<home>/.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
13 changes: 13 additions & 0 deletions tests/container/test_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import pytest

from panopticon.container.cli.claude import INTERRUPT_PROMPT, ClaudeAgentCLI
from panopticon.container.hooks import THEME


class _FakeClient:
Expand Down Expand Up @@ -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:
Expand Down
24 changes: 23 additions & 1 deletion tests/container/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
Loading