Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src/pinky_daemon/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
)
from pinky_daemon.autonomy import AgentEvent, AutonomyEngine, EventType
from pinky_daemon.broker import BrokerMessage, MessageBroker
from pinky_daemon.claude_config import resolve_agent_claude_config_dir
from pinky_daemon.conversation_store import ConversationStore
from pinky_daemon.dream_runner import DreamRunner
from pinky_daemon.effort import CLI_EFFORT_LEVELS, EFFORT_LEVELS
Expand Down Expand Up @@ -5853,6 +5854,12 @@ async def transport_transcript_path(
(Path(container_config_dir(str(Path(wd).resolve()))) / "projects")
.resolve()
)
per_agent_cfg = resolve_agent_claude_config_dir(
agent,
working_dir=agent.working_dir,
)
if per_agent_cfg is not None:
allowed_roots.append((per_agent_cfg / "projects").resolve())
try:
normalised = path.resolve(strict=False)
except (OSError, RuntimeError) as e:
Expand Down
181 changes: 181 additions & 0 deletions src/pinky_daemon/claude_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Shared Claude Code config-dir helpers.

This module is deliberately dependency-light so tmux, SDK streaming, dreams,
and API validation can all use the same path resolver without import cycles.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import tempfile
import threading
from collections.abc import Mapping
from pathlib import Path

PER_AGENT_CLAUDE_CONFIG_ENV = "PINKY_PER_AGENT_CLAUDE_CONFIG"
PER_AGENT_CLAUDE_CONFIG_DIRNAME = ".claude-config"

# Serializes read-modify-write of ``.claude.json`` across concurrent local
# launches so two simultaneous seeds cannot drop each other's project entry.
_CLAUDE_JSON_SEED_LOCK = threading.Lock()


def env_flag_enabled(
name: str,
env: Mapping[str, str] | None = None,
*,
default: str = "0",
) -> bool:
e = env if env is not None else os.environ
return str(e.get(name, default)).strip().lower() in ("1", "true", "yes", "on")


def claude_project_slug(project_dir: str | Path) -> str:
"""Return Claude Code's ``projects/`` slug for an absolute cwd."""
cwd = Path(project_dir).resolve()
return re.sub(r"[^a-zA-Z0-9]", "-", str(cwd))


def resolve_claude_config_path(env: Mapping[str, str] | None = None) -> Path:
"""Resolve Claude Code's global ``.claude.json`` path.

Mirrors the CLI: ``$CLAUDE_CONFIG_DIR/.claude.json`` when set, otherwise
``$HOME/.claude.json``.
"""
e = env if env is not None else os.environ
cfg_dir = (e.get("CLAUDE_CONFIG_DIR") or "").strip()
base = Path(cfg_dir) if cfg_dir else Path(e.get("HOME") or Path.home())
return base / ".claude.json"


def resolve_claude_settings_path(env: Mapping[str, str] | None = None) -> Path:
"""Resolve the settings file that carries first-run prompt suppressors."""
e = env if env is not None else os.environ
cfg_dir = (e.get("CLAUDE_CONFIG_DIR") or "").strip()
if cfg_dir:
return Path(cfg_dir) / "settings.json"
home = Path(e.get("HOME") or Path.home())
return home / ".claude" / "settings.json"


def resolve_agent_claude_config_dir(
agent: object,
*,
working_dir: str | Path | None = None,
env: Mapping[str, str] | None = None,
) -> Path | None:
"""Return this local agent's per-agent ``CLAUDE_CONFIG_DIR`` or ``None``.

Guardrails:
- flag-gated and default-off;
- local-runtime agents only (container/unix_user provisioning owns its own
config dir);
- static-token fail-safe: without ``CLAUDE_CODE_OAUTH_TOKEN`` present at
resolve time, skip the per-agent dir rather than booting into a login wall.
"""
e = env if env is not None else os.environ
if not env_flag_enabled(PER_AGENT_CLAUDE_CONFIG_ENV, e):
return None
if agent is None:
return None
if getattr(agent, "isolation_mode", "local") not in ("", "local"):
return None
if not (e.get("CLAUDE_CODE_OAUTH_TOKEN") or "").strip():
return None

raw_wd = working_dir if working_dir is not None else getattr(agent, "working_dir", "")
wd = str(raw_wd or "").strip()
if not wd:
return None
return Path(wd).resolve() / PER_AGENT_CLAUDE_CONFIG_DIRNAME


def migrate_claude_project_history(project_dir: str | Path, config_dir: Path) -> bool:
"""Copy the existing global transcript project dir into ``config_dir``.

Idempotent and non-clobbering: if the destination project dir already
exists, leave it alone. The slug remains derived from the project cwd.
"""
slug = claude_project_slug(project_dir)
src = Path.home() / ".claude" / "projects" / slug
dst = config_dir / "projects" / slug
if not src.exists() or dst.exists():
return False
dst.parent.mkdir(parents=True, exist_ok=True)
tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.pinky-copy.", dir=dst.parent))
try:
shutil.copytree(src, tmp, dirs_exist_ok=True)
if dst.exists():
shutil.rmtree(tmp, ignore_errors=True)
return False
os.replace(tmp, dst)
except Exception:
shutil.rmtree(tmp, ignore_errors=True)
raise
return True


def seed_claude_trust_file(config_path: Path, project_dir: str | Path) -> bool:
"""Idempotently pre-seed Claude Code first-run trust/onboarding flags."""
proj_key = str(Path(project_dir).resolve())
with _CLAUDE_JSON_SEED_LOCK:
data: dict = {}
if config_path.exists():
with config_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(
f"{config_path} root is not a JSON object "
f"(got {type(data).__name__}) -- refusing to overwrite"
)

changed = False
for flag in ("bypassPermissionsModeAccepted", "hasCompletedOnboarding"):
if data.get(flag) is not True:
data[flag] = True
changed = True

projects = data.setdefault("projects", {})
if not isinstance(projects, dict):
raise ValueError(f"{config_path} 'projects' is not an object")
proj = projects.setdefault(proj_key, {})
if not isinstance(proj, dict):
raise ValueError(f"{config_path} projects[{proj_key!r}] is not an object")
for flag in ("hasTrustDialogAccepted", "hasCompletedProjectOnboarding"):
if proj.get(flag) is not True:
proj[flag] = True
changed = True

if changed:
config_path.parent.mkdir(parents=True, exist_ok=True)
tmp = config_path.parent / f".claude.json.pinky-seed.{os.getpid()}.tmp"
with tmp.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp, config_path)
return changed


def seed_claude_settings_file(settings_path: Path) -> bool:
"""Seed the settings flag that suppresses the dangerous-mode prompt."""
with _CLAUDE_JSON_SEED_LOCK:
data: dict = {}
if settings_path.exists():
with settings_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(
f"{settings_path} root is not a JSON object "
f"(got {type(data).__name__}) -- refusing to overwrite"
)
if data.get("skipDangerousModePermissionPrompt") is True:
return False
data["skipDangerousModePermissionPrompt"] = True
settings_path.parent.mkdir(parents=True, exist_ok=True)
tmp = settings_path.parent / f".claude-settings.pinky-seed.{os.getpid()}.tmp"
with tmp.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp, settings_path)
return True
1 change: 1 addition & 0 deletions src/pinky_daemon/dream_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ async def run_dream(self, agent_name: str, agent_config) -> str:
system_prompt=system_prompt,
# SDK allowlist + Read/Write for the file-passing protocol
allowed_tools=["Read", "Write", *_DREAM_ALLOWED_TOOLS],
agent_config=agent_config,
),
agent_name=agent_name,
)
Expand Down
65 changes: 65 additions & 0 deletions src/pinky_daemon/streaming_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@
import asyncio
import hashlib
import json
import os
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path

from pinky_daemon.claude_config import (
migrate_claude_project_history,
resolve_agent_claude_config_dir,
resolve_claude_config_path,
resolve_claude_settings_path,
seed_claude_settings_file,
seed_claude_trust_file,
)
from pinky_daemon.effort import CLI_EFFORT_LEVELS, resolve_cli_effort
from pinky_daemon.sessions import SessionUsage
from pinky_daemon.transport_state import SessionState, StateMachine, Trigger
Expand Down Expand Up @@ -308,6 +317,53 @@ def __init__(
self._turn_seq = 0 # Monotonic turn counter for analytics
self._last_user_message = "" # For analytics keyword classification

def _registry_agent(self):
if not self._registry or not self.agent_name:
return None
try:
return self._registry.get(self.agent_name)
except Exception:
return None

def _per_agent_claude_config_dir(self) -> Path | None:
return resolve_agent_claude_config_dir(
self._registry_agent(),
working_dir=self._config.working_dir,
)

def _per_agent_claude_env(self) -> dict[str, str]:
cfg_dir = self._per_agent_claude_config_dir()
if cfg_dir is None:
return {}
env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)}
if not (self._config.provider_url or self._config.provider_key):
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if token:
env["CLAUDE_CODE_OAUTH_TOKEN"] = token
return env

def _prepare_claude_config(self) -> None:
cfg_dir = self._per_agent_claude_config_dir()
if cfg_dir is None:
return
project_dir = Path(self._config.working_dir or ".").resolve()
if migrate_claude_project_history(project_dir, cfg_dir):
_log(
f"streaming[{self.agent_name}]: migrated claude transcript "
f"history into {cfg_dir}"
)
env = {"CLAUDE_CONFIG_DIR": str(cfg_dir)}
if seed_claude_settings_file(resolve_claude_settings_path(env)):
_log(
f"streaming[{self.agent_name}]: pre-seeded claude settings "
f"in {cfg_dir}"
)
if seed_claude_trust_file(resolve_claude_config_path(env), project_dir):
_log(
f"streaming[{self.agent_name}]: pre-seeded claude trust flags "
f"in {cfg_dir} for project {project_dir}"
)

async def connect(self) -> None:
"""Connect to Claude Code. Starts the reader loop.

Expand Down Expand Up @@ -414,6 +470,14 @@ async def connect(self) -> None:
except Exception as e:
_log(f"streaming[{self.agent_name}]: failed to read .mcp.json: {e}")

try:
self._prepare_claude_config()
except Exception as e:
_log(
f"streaming[{self.agent_name}]: claude config prep failed "
f"(non-fatal): {e}"
)

options = ClaudeAgentOptions(
cwd=self._config.working_dir,
allowed_tools=self._config.allowed_tools or DEFAULT_STREAMING_ALLOWED_TOOLS,
Expand Down Expand Up @@ -450,6 +514,7 @@ async def connect(self) -> None:
if self._config.provider_key:
provider_env["ANTHROPIC_API_KEY"] = self._config.provider_key
provider_env["ANTHROPIC_AUTH_TOKEN"] = self._config.provider_key
provider_env.update(self._per_agent_claude_env())

# #429: surface configured effort + agent identity to CLI hooks so
# hook_verify_effort.py can detect drift from PINKY_EXPECTED_EFFORT
Expand Down
Loading
Loading