diff --git a/CHANGELOG.md b/CHANGELOG.md index 2baba4a..ac4f6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ For each release we list user-facing changes grouped as **Added**, **Changed**, - **Provider SDKs load on first use instead of at module import** (#370). `import notebook_intelligence` no longer imports `litellm`, `openai`, `ollama`, or the `anthropic` SDK; `litellm`, `openai`, and `anthropic` load the first time their provider is actually used (for Claude mode that includes the client construction and model refresh NBI runs at startup), while `ollama` still loads during extension startup when the provider enumerates local models. This roughly halves the server-extension import time (a cost the Jupyter server pays on every start), with the biggest effect on Windows machines where antivirus scanning amplifies the many-small-file SDK imports (#368). When NBI does load litellm, it now defaults `LITELLM_LOCAL_MODEL_COST_MAP=true` so litellm reads its bundled model-cost map rather than fetching it over HTTP at import; set the env var to `false` to restore the fetch. +### Fixed + +- **Session history follows `CLAUDE_CONFIG_DIR`** (#373). The chat-sidebar resume picker and the launcher tile's session list always read transcripts from `~/.claude/projects`, so both came up empty when the Claude CLI was configured with `CLAUDE_CONFIG_DIR` and wrote its transcripts elsewhere. The session listing now resolves the CLI's config dir the same way the skills and spinner-verbs paths already did. + ## [5.1.0] - UNRELEASED 5.1.0 builds on 5.0.x with a focus on Claude-mode agent visibility. Tool calls the agent runs now render as persistent status cards with inline diffs and collapsible grouping, the generating indicator can cycle custom verbs, and cancelling a turn tears down the whole process tree the agent spawned instead of leaking it. It also adds two opt-in security guardrails (an MCP stdio-command allowlist and a default-token-password check on shared filesystems) and an always-visible mode for chat feedback. No traitlet, env-var, REST route, or on-disk-format renames or removals; every new admin surface is opt-in and listed below. diff --git a/docs/admin-guide.md b/docs/admin-guide.md index e6a5e6d..c109100 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -46,16 +46,16 @@ Manual edits to `config.json` while JupyterLab is running require a JupyterLab r ## Persistent-volume layout -| Path | Persist? | Notes | -| ----------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `~/.jupyter/nbi/config.json` | Yes | User's chosen provider, models, MCP servers, plus plaintext API keys. Treat as a secret. | -| `~/.jupyter/nbi/user-data.json` | Yes | Encrypted GitHub Copilot access token, written when "remember login" is enabled. Encrypted with `NBI_GH_ACCESS_TOKEN_PASSWORD`. | -| `~/.jupyter/nbi/rules/` | Yes | User's ruleset markdown files. | -| `~/.jupyter/nbi/mcp.json` | Yes | User's MCP server config (alternative to managing via the Settings dialog). | -| `~/.claude/skills/` | Yes | User-scope Claude skills (including managed skills). | -| `~/.claude/projects/` | Yes | Claude Code session transcripts. Required for "Resume previous Claude session". Managed by Claude CLI, not NBI. | -| `/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. | -| Project-scope `/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. | +| Path | Persist? | Notes | +| ----------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.jupyter/nbi/config.json` | Yes | User's chosen provider, models, MCP servers, plus plaintext API keys. Treat as a secret. | +| `~/.jupyter/nbi/user-data.json` | Yes | Encrypted GitHub Copilot access token, written when "remember login" is enabled. Encrypted with `NBI_GH_ACCESS_TOKEN_PASSWORD`. | +| `~/.jupyter/nbi/rules/` | Yes | User's ruleset markdown files. | +| `~/.jupyter/nbi/mcp.json` | Yes | User's MCP server config (alternative to managing via the Settings dialog). | +| `~/.claude/skills/` | Yes | User-scope Claude skills (including managed skills). | +| `~/.claude/projects/` | Yes | Claude Code session transcripts. Required for "Resume previous Claude session". Managed by Claude CLI, not NBI. When `CLAUDE_CONFIG_DIR` is set, this (and `~/.claude/skills/`) lives under `$CLAUDE_CONFIG_DIR` instead; NBI follows the override. | +| `/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. | +| Project-scope `/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. | For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure `~/.jupyter` and `~/.claude` are inside that mount. Anything else (`/tmp`, `~/.cache`) can be ephemeral. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7a774c7..019207b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -112,6 +112,8 @@ ls ~/.claude/skills/ # Claude skills ls ~/.claude/projects/ # Claude session transcripts ``` +If `CLAUDE_CONFIG_DIR` is set, the Claude CLI keeps its skills and session transcripts under `$CLAUDE_CONFIG_DIR` instead of `~/.claude`, and NBI reads from the same place. + > Do not share the contents of `~/.jupyter/nbi/config.json` or `~/.jupyter/nbi/user-data.json` — they contain API keys or your encrypted GitHub token. ## "Skills reloaded" banner keeps appearing diff --git a/notebook_intelligence/claude_sessions.py b/notebook_intelligence/claude_sessions.py index 2c4efe3..763673f 100644 --- a/notebook_intelligence/claude_sessions.py +++ b/notebook_intelligence/claude_sessions.py @@ -4,10 +4,12 @@ Claude Code persists each conversation as a line-delimited JSON file at:: - ~/.claude/projects//.jsonl + /projects//.jsonl -where ```` is the session cwd with path separators replaced by -dashes (e.g. ``/Users/me/proj`` -> ``-Users-me-proj``). +where ```` is ``~/.claude`` unless the CLI's +``CLAUDE_CONFIG_DIR`` env var overrides it, and ```` is the +session cwd with path separators replaced by dashes (e.g. +``/Users/me/proj`` -> ``-Users-me-proj``). This module reads those files for the current Jupyter working directory and returns lightweight metadata (id, timestamps, first user message preview) so @@ -32,6 +34,8 @@ from pathlib import Path from typing import Optional +from notebook_intelligence.util import get_claude_config_dir + log = logging.getLogger(__name__) _PREVIEW_MAX_CHARS = 160 @@ -104,10 +108,10 @@ def encode_cwd(cwd: str) -> str: def get_sessions_dir(cwd: str, claude_home: Optional[str] = None) -> Path: """Return the directory containing session transcripts for ``cwd``. - ``claude_home`` defaults to ``~/.claude`` but can be overridden (useful - for tests and for the ``CLAUDE_CONFIG_DIR`` convention). + ``claude_home`` defaults to the CLI's own config dir (``CLAUDE_CONFIG_DIR`` + when set, else ``~/.claude``) but can be overridden, mainly for tests. """ - home = Path(claude_home) if claude_home else Path.home() / ".claude" + home = Path(claude_home) if claude_home else Path(get_claude_config_dir()) return home / "projects" / encode_cwd(cwd) @@ -393,8 +397,8 @@ def list_all_sessions( ) -> list[ClaudeSessionInfo]: """List every resumable Claude session on disk, newest first. - Walks ``~/.claude/projects/*/`` directly so the result is the same - set of sessions ``claude --resume`` can recover. ``history.jsonl`` is + Walks ``/projects/*/`` directly so the result is the + same set of sessions ``claude --resume`` can recover. ``history.jsonl`` is NOT used as a gating source because recent Claude Code versions don't reliably populate it (notably for SDK-driven invocations), and history-first lookups silently dropped real, on-disk sessions. @@ -412,7 +416,7 @@ def list_all_sessions( activity. Per-transcript parse results are mtime-cached so repeated calls don't reparse every file. """ - home = Path(claude_home) if claude_home else Path.home() / ".claude" + home = Path(claude_home) if claude_home else Path(get_claude_config_dir()) projects_dir = home / "projects" sessions: list[ClaudeSessionInfo] = [] diff --git a/notebook_intelligence/config.py b/notebook_intelligence/config.py index bd0dae7..60f1e58 100644 --- a/notebook_intelligence/config.py +++ b/notebook_intelligence/config.py @@ -8,6 +8,7 @@ import tempfile from typing import Optional +from notebook_intelligence.util import get_claude_config_dir from notebook_intelligence.feature_flags import ( CHAT_MODEL_OVERRIDES, CLAUDE_SETTINGS_OVERRIDES, @@ -219,8 +220,7 @@ def rules_directory(self) -> str: def user_skills_directory(self) -> str: # Mirrors Claude's own CLAUDE_CONFIG_DIR override so the extension picks up # skills from the same directory Claude is using. - base = os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude') - return os.path.join(base, 'skills') + return os.path.join(get_claude_config_dir(), 'skills') def project_skills_directory(self, project_root: str) -> str: return os.path.join(project_root, '.claude', 'skills') diff --git a/notebook_intelligence/extension.py b/notebook_intelligence/extension.py index 75f52d2..d3a118d 100644 --- a/notebook_intelligence/extension.py +++ b/notebook_intelligence/extension.py @@ -63,7 +63,7 @@ ) import notebook_intelligence.github_copilot as github_copilot from notebook_intelligence.built_in_toolsets import built_in_toolsets -from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, VALID_CODING_AGENT_LAUNCHERS, compute_effective_disabled_launchers, validate_coding_agent_launcher_ids, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path, resolve_codex_cli_path, safe_anchor_uri, has_dangerous_text_codepoints, split_csv +from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_claude_config_dir, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, VALID_CODING_AGENT_LAUNCHERS, compute_effective_disabled_launchers, validate_coding_agent_launcher_ids, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path, resolve_codex_cli_path, safe_anchor_uri, has_dangerous_text_codepoints, split_csv from notebook_intelligence.context_factory import RuleContextFactory from notebook_intelligence.skillset import SKILL_NAME_REGEX @@ -428,10 +428,7 @@ def _scrub_credentials_for_wire(claude_settings: dict, string_overrides: dict) - def _read_claude_spinner_verbs() -> Optional[dict]: - settings_path = os.path.join( - os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude'), - 'settings.json' - ) + settings_path = os.path.join(get_claude_config_dir(), 'settings.json') try: with open(settings_path) as f: data = json.load(f) diff --git a/notebook_intelligence/util.py b/notebook_intelligence/util.py index 544fc04..fbe4e76 100644 --- a/notebook_intelligence/util.py +++ b/notebook_intelligence/util.py @@ -95,6 +95,19 @@ def safe_jupyter_path(path: str) -> Path: return target_path +def get_claude_config_dir() -> str: + """Claude Code's config dir: CLAUDE_CONFIG_DIR when set, else ``~/.claude``. + + Mirrors the CLI's own override so every surface that reads CLI-owned + state (session transcripts, skills, settings.json) looks where the CLI + actually writes. Read per call rather than memoized: it's a dict lookup, + and tests toggle the env var between cases. + """ + return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join( + os.path.expanduser("~"), ".claude" + ) + + _cached_cli_paths: dict[str, Optional[str]] = {} diff --git a/tests/test_claude_sessions.py b/tests/test_claude_sessions.py index ade4950..f1eb506 100644 --- a/tests/test_claude_sessions.py +++ b/tests/test_claude_sessions.py @@ -974,3 +974,64 @@ def counting_reader(path): second = list_all_sessions(claude_home=str(fake_claude_home)) assert len(second) == 1 assert call_count["n"] == 0 # served from cache + + +class TestClaudeConfigDirDefault: + """The claude_home default must follow CLAUDE_CONFIG_DIR (issue #373). + + The CLI writes transcripts under $CLAUDE_CONFIG_DIR/projects when the + env var is set; the handler calls list_all_sessions without a + claude_home, so the default is what production traffic exercises. + """ + + def test_get_sessions_dir_honors_claude_config_dir( + self, monkeypatch, tmp_path + ): + override = tmp_path / "workspace" / ".claude" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + result = get_sessions_dir("/some/cwd") + assert result == override / "projects" / encode_cwd("/some/cwd") + + def test_get_sessions_dir_defaults_to_home_claude( + self, monkeypatch, tmp_path + ): + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + result = get_sessions_dir("/some/cwd") + assert result == tmp_path / ".claude" / "projects" / encode_cwd( + "/some/cwd" + ) + + def test_list_all_sessions_reads_claude_config_dir( + self, monkeypatch, tmp_path + ): + override = tmp_path / "workspace" / ".claude" + cwd = str(tmp_path / "proj") + _write_jsonl( + override / "projects" / encode_cwd(cwd) / "abc.jsonl", + [_user_line("abc", "hello from the override dir", cwd=cwd)], + ) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + + sessions = list_all_sessions() + + assert [s.session_id for s in sessions] == ["abc"] + assert sessions[0].preview == "hello from the override dir" + + def test_list_all_sessions_ignores_home_claude_when_overridden( + self, monkeypatch, tmp_path + ): + home = tmp_path / "home" + cwd = str(tmp_path / "proj") + _write_jsonl( + home / ".claude" / "projects" / encode_cwd(cwd) / "old.jsonl", + [_user_line("old", "stale home transcript", cwd=cwd)], + ) + override = tmp_path / "workspace" / ".claude" + override.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + + assert list_all_sessions() == []