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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
| `<env-prefix>/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. |
| Project-scope `<project>/.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. |
| `<env-prefix>/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. |
| Project-scope `<project>/.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.

Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 13 additions & 9 deletions notebook_intelligence/claude_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

Claude Code persists each conversation as a line-delimited JSON file at::

~/.claude/projects/<cwd-encoded>/<session-id>.jsonl
<claude-config-dir>/projects/<cwd-encoded>/<session-id>.jsonl

where ``<cwd-encoded>`` is the session cwd with path separators replaced by
dashes (e.g. ``/Users/me/proj`` -> ``-Users-me-proj``).
where ``<claude-config-dir>`` is ``~/.claude`` unless the CLI's
``CLAUDE_CONFIG_DIR`` env var overrides it, and ``<cwd-encoded>`` 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
Expand All @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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 ``<claude-config-dir>/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.
Expand All @@ -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] = []
Expand Down
4 changes: 2 additions & 2 deletions notebook_intelligence/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down
7 changes: 2 additions & 5 deletions notebook_intelligence/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions notebook_intelligence/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}


Expand Down
61 changes: 61 additions & 0 deletions tests/test_claude_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == []
Loading