Skip to content

Commit 7e6f961

Browse files
authored
fix(claude): honor CLAUDE_CONFIG_DIR when listing session history (#374)
2 parents bd81111 + e0a9fd5 commit 7e6f961

8 files changed

Lines changed: 107 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ For each release we list user-facing changes grouped as **Added**, **Changed**,
1414

1515
- **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.
1616

17+
### Fixed
18+
19+
- **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.
20+
1721
## [5.1.0] - UNRELEASED
1822

1923
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.

docs/admin-guide.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,16 @@ Manual edits to `config.json` while JupyterLab is running require a JupyterLab r
4646

4747
## Persistent-volume layout
4848

49-
| Path | Persist? | Notes |
50-
| ----------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
51-
| `~/.jupyter/nbi/config.json` | Yes | User's chosen provider, models, MCP servers, plus plaintext API keys. Treat as a secret. |
52-
| `~/.jupyter/nbi/user-data.json` | Yes | Encrypted GitHub Copilot access token, written when "remember login" is enabled. Encrypted with `NBI_GH_ACCESS_TOKEN_PASSWORD`. |
53-
| `~/.jupyter/nbi/rules/` | Yes | User's ruleset markdown files. |
54-
| `~/.jupyter/nbi/mcp.json` | Yes | User's MCP server config (alternative to managing via the Settings dialog). |
55-
| `~/.claude/skills/` | Yes | User-scope Claude skills (including managed skills). |
56-
| `~/.claude/projects/` | Yes | Claude Code session transcripts. Required for "Resume previous Claude session". Managed by Claude CLI, not NBI. |
57-
| `<env-prefix>/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. |
58-
| Project-scope `<project>/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. |
49+
| Path | Persist? | Notes |
50+
| ----------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51+
| `~/.jupyter/nbi/config.json` | Yes | User's chosen provider, models, MCP servers, plus plaintext API keys. Treat as a secret. |
52+
| `~/.jupyter/nbi/user-data.json` | Yes | Encrypted GitHub Copilot access token, written when "remember login" is enabled. Encrypted with `NBI_GH_ACCESS_TOKEN_PASSWORD`. |
53+
| `~/.jupyter/nbi/rules/` | Yes | User's ruleset markdown files. |
54+
| `~/.jupyter/nbi/mcp.json` | Yes | User's MCP server config (alternative to managing via the Settings dialog). |
55+
| `~/.claude/skills/` | Yes | User-scope Claude skills (including managed skills). |
56+
| `~/.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. |
57+
| `<env-prefix>/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. |
58+
| Project-scope `<project>/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. |
5959

6060
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.
6161

docs/troubleshooting.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ ls ~/.claude/skills/ # Claude skills
112112
ls ~/.claude/projects/ # Claude session transcripts
113113
```
114114

115+
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.
116+
115117
> 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.
116118
117119
## "Skills reloaded" banner keeps appearing

notebook_intelligence/claude_sessions.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
55
Claude Code persists each conversation as a line-delimited JSON file at::
66
7-
~/.claude/projects/<cwd-encoded>/<session-id>.jsonl
7+
<claude-config-dir>/projects/<cwd-encoded>/<session-id>.jsonl
88
9-
where ``<cwd-encoded>`` is the session cwd with path separators replaced by
10-
dashes (e.g. ``/Users/me/proj`` -> ``-Users-me-proj``).
9+
where ``<claude-config-dir>`` is ``~/.claude`` unless the CLI's
10+
``CLAUDE_CONFIG_DIR`` env var overrides it, and ``<cwd-encoded>`` is the
11+
session cwd with path separators replaced by dashes (e.g.
12+
``/Users/me/proj`` -> ``-Users-me-proj``).
1113
1214
This module reads those files for the current Jupyter working directory and
1315
returns lightweight metadata (id, timestamps, first user message preview) so
@@ -32,6 +34,8 @@
3234
from pathlib import Path
3335
from typing import Optional
3436

37+
from notebook_intelligence.util import get_claude_config_dir
38+
3539
log = logging.getLogger(__name__)
3640

3741
_PREVIEW_MAX_CHARS = 160
@@ -104,10 +108,10 @@ def encode_cwd(cwd: str) -> str:
104108
def get_sessions_dir(cwd: str, claude_home: Optional[str] = None) -> Path:
105109
"""Return the directory containing session transcripts for ``cwd``.
106110
107-
``claude_home`` defaults to ``~/.claude`` but can be overridden (useful
108-
for tests and for the ``CLAUDE_CONFIG_DIR`` convention).
111+
``claude_home`` defaults to the CLI's own config dir (``CLAUDE_CONFIG_DIR``
112+
when set, else ``~/.claude``) but can be overridden, mainly for tests.
109113
"""
110-
home = Path(claude_home) if claude_home else Path.home() / ".claude"
114+
home = Path(claude_home) if claude_home else Path(get_claude_config_dir())
111115
return home / "projects" / encode_cwd(cwd)
112116

113117

@@ -393,8 +397,8 @@ def list_all_sessions(
393397
) -> list[ClaudeSessionInfo]:
394398
"""List every resumable Claude session on disk, newest first.
395399
396-
Walks ``~/.claude/projects/*/`` directly so the result is the same
397-
set of sessions ``claude --resume`` can recover. ``history.jsonl`` is
400+
Walks ``<claude-config-dir>/projects/*/`` directly so the result is the
401+
same set of sessions ``claude --resume`` can recover. ``history.jsonl`` is
398402
NOT used as a gating source because recent Claude Code versions don't
399403
reliably populate it (notably for SDK-driven invocations), and
400404
history-first lookups silently dropped real, on-disk sessions.
@@ -412,7 +416,7 @@ def list_all_sessions(
412416
activity. Per-transcript parse results are mtime-cached so repeated
413417
calls don't reparse every file.
414418
"""
415-
home = Path(claude_home) if claude_home else Path.home() / ".claude"
419+
home = Path(claude_home) if claude_home else Path(get_claude_config_dir())
416420
projects_dir = home / "projects"
417421

418422
sessions: list[ClaudeSessionInfo] = []

notebook_intelligence/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import tempfile
99
from typing import Optional
1010

11+
from notebook_intelligence.util import get_claude_config_dir
1112
from notebook_intelligence.feature_flags import (
1213
CHAT_MODEL_OVERRIDES,
1314
CLAUDE_SETTINGS_OVERRIDES,
@@ -219,8 +220,7 @@ def rules_directory(self) -> str:
219220
def user_skills_directory(self) -> str:
220221
# Mirrors Claude's own CLAUDE_CONFIG_DIR override so the extension picks up
221222
# skills from the same directory Claude is using.
222-
base = os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude')
223-
return os.path.join(base, 'skills')
223+
return os.path.join(get_claude_config_dir(), 'skills')
224224

225225
def project_skills_directory(self, project_root: str) -> str:
226226
return os.path.join(project_root, '.claude', 'skills')

notebook_intelligence/extension.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
)
6464
import notebook_intelligence.github_copilot as github_copilot
6565
from notebook_intelligence.built_in_toolsets import built_in_toolsets
66-
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
66+
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
6767
from notebook_intelligence.context_factory import RuleContextFactory
6868
from notebook_intelligence.skillset import SKILL_NAME_REGEX
6969

@@ -428,10 +428,7 @@ def _scrub_credentials_for_wire(claude_settings: dict, string_overrides: dict) -
428428

429429

430430
def _read_claude_spinner_verbs() -> Optional[dict]:
431-
settings_path = os.path.join(
432-
os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude'),
433-
'settings.json'
434-
)
431+
settings_path = os.path.join(get_claude_config_dir(), 'settings.json')
435432
try:
436433
with open(settings_path) as f:
437434
data = json.load(f)

notebook_intelligence/util.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,19 @@ def safe_jupyter_path(path: str) -> Path:
9595
return target_path
9696

9797

98+
def get_claude_config_dir() -> str:
99+
"""Claude Code's config dir: CLAUDE_CONFIG_DIR when set, else ``~/.claude``.
100+
101+
Mirrors the CLI's own override so every surface that reads CLI-owned
102+
state (session transcripts, skills, settings.json) looks where the CLI
103+
actually writes. Read per call rather than memoized: it's a dict lookup,
104+
and tests toggle the env var between cases.
105+
"""
106+
return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join(
107+
os.path.expanduser("~"), ".claude"
108+
)
109+
110+
98111
_cached_cli_paths: dict[str, Optional[str]] = {}
99112

100113

tests/test_claude_sessions.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,3 +974,64 @@ def counting_reader(path):
974974
second = list_all_sessions(claude_home=str(fake_claude_home))
975975
assert len(second) == 1
976976
assert call_count["n"] == 0 # served from cache
977+
978+
979+
class TestClaudeConfigDirDefault:
980+
"""The claude_home default must follow CLAUDE_CONFIG_DIR (issue #373).
981+
982+
The CLI writes transcripts under $CLAUDE_CONFIG_DIR/projects when the
983+
env var is set; the handler calls list_all_sessions without a
984+
claude_home, so the default is what production traffic exercises.
985+
"""
986+
987+
def test_get_sessions_dir_honors_claude_config_dir(
988+
self, monkeypatch, tmp_path
989+
):
990+
override = tmp_path / "workspace" / ".claude"
991+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override))
992+
result = get_sessions_dir("/some/cwd")
993+
assert result == override / "projects" / encode_cwd("/some/cwd")
994+
995+
def test_get_sessions_dir_defaults_to_home_claude(
996+
self, monkeypatch, tmp_path
997+
):
998+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
999+
monkeypatch.setenv("HOME", str(tmp_path))
1000+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
1001+
result = get_sessions_dir("/some/cwd")
1002+
assert result == tmp_path / ".claude" / "projects" / encode_cwd(
1003+
"/some/cwd"
1004+
)
1005+
1006+
def test_list_all_sessions_reads_claude_config_dir(
1007+
self, monkeypatch, tmp_path
1008+
):
1009+
override = tmp_path / "workspace" / ".claude"
1010+
cwd = str(tmp_path / "proj")
1011+
_write_jsonl(
1012+
override / "projects" / encode_cwd(cwd) / "abc.jsonl",
1013+
[_user_line("abc", "hello from the override dir", cwd=cwd)],
1014+
)
1015+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override))
1016+
1017+
sessions = list_all_sessions()
1018+
1019+
assert [s.session_id for s in sessions] == ["abc"]
1020+
assert sessions[0].preview == "hello from the override dir"
1021+
1022+
def test_list_all_sessions_ignores_home_claude_when_overridden(
1023+
self, monkeypatch, tmp_path
1024+
):
1025+
home = tmp_path / "home"
1026+
cwd = str(tmp_path / "proj")
1027+
_write_jsonl(
1028+
home / ".claude" / "projects" / encode_cwd(cwd) / "old.jsonl",
1029+
[_user_line("old", "stale home transcript", cwd=cwd)],
1030+
)
1031+
override = tmp_path / "workspace" / ".claude"
1032+
override.mkdir(parents=True)
1033+
monkeypatch.setenv("HOME", str(home))
1034+
monkeypatch.setenv("USERPROFILE", str(home))
1035+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override))
1036+
1037+
assert list_all_sessions() == []

0 commit comments

Comments
 (0)