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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ 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.

## [5.1.0] - UNRELEASED
### 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.
- **User-scope MCP config and the plugin cache follow `CLAUDE_CONFIG_DIR`** (#375). The MCP management tab read user-scope servers from `~/.claude.json` even though the CLI relocates that file to `$CLAUDE_CONFIG_DIR/.claude.json` when the override is set (so reads and CLI-mediated writes diverged), and the Plugins panel's cache fallback pointed at `~/.claude/plugins` instead of the relocated cache. Both now resolve the CLI's actual locations; `CLAUDE_CODE_PLUGIN_CACHE_DIR` still wins for the plugin cache when set.

## [5.1.0] - 2026-06-08

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 Expand Up @@ -378,7 +383,8 @@ A multi-PR accessibility pass landed across most NBI surfaces. Together these ma
- Settings UI restructured around Claude vs default mode.
- WebSocket connection reliability improvements.

[unreleased]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.1...HEAD
[unreleased]: https://github.com/plmbr/notebook-intelligence/compare/v5.1.0...HEAD
[5.1.0]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.1...v5.1.0
[5.0.1]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.0...v5.0.1
[5.0.0]: https://github.com/plmbr/notebook-intelligence/compare/v4.8.0...v5.0.0
[4.8.0]: https://github.com/plmbr/notebook-intelligence/compare/v4.7.0...v4.8.0
Expand Down
28 changes: 14 additions & 14 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,18 @@ 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. |

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.
| 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 (or `$CLAUDE_CONFIG_DIR`, if you point the Claude CLI elsewhere). Anything else (`/tmp`, `~/.cache`) can be ephemeral.

---

Expand Down Expand Up @@ -474,7 +474,7 @@ Force-off:
- Hides the Claude-mode **MCP Servers** tab in the Settings panel (visible only when Claude mode is on and the `claude` CLI is available).
- Returns HTTP 403 from every `/notebook-intelligence/claude-mcp/*` route.

The Claude-mode tab is **independent** of the existing non-Claude **MCP Servers** tab. The former wraps Claude Code's own config (`~/.claude.json` and project `.mcp.json`); the latter manages NBI's own MCP servers used by the non-Claude chat path. They never appear at the same time the non-Claude tab is hidden when Claude mode is on, and the Claude-mode tab is hidden when it's off.
The Claude-mode tab is **independent** of the existing non-Claude **MCP Servers** tab. The former wraps Claude Code's own config (`~/.claude.json`, relocated to `$CLAUDE_CONFIG_DIR/.claude.json` when that env var is set, and project `.mcp.json`); the latter manages NBI's own MCP servers used by the non-Claude chat path. They never appear at the same time; the non-Claude tab is hidden when Claude mode is on, and the Claude-mode tab is hidden when it's off.

Reads come from Claude's JSON config files directly (fast, no health checks). Writes (add / remove) shell out to `claude mcp add` / `claude mcp remove` so Claude remains the source of truth for any side effects (project-trust prompts, OAuth bookkeeping).

Expand Down Expand Up @@ -516,7 +516,7 @@ c.NotebookIntelligence.claude_plugins_management_policy = "force-off"

Or via env: `NBI_CLAUDE_PLUGINS_MANAGEMENT_POLICY=force-off`.

Force-off hides the **Plugins** tab and returns 403 from every `/notebook-intelligence/plugins/*` route. The tab is otherwise visible only when Claude mode is on and the `claude` CLI is available. Both reads (`claude plugin list --json`) and writes (`claude plugin install` / `uninstall` / `enable` / `disable` / `marketplace add` / `marketplace remove`) shell out to the Claude CLI; Claude owns the plugin state under `~/.claude/plugins/`.
Force-off hides the **Plugins** tab and returns 403 from every `/notebook-intelligence/plugins/*` route. The tab is otherwise visible only when Claude mode is on and the `claude` CLI is available. Both reads (`claude plugin list --json`) and writes (`claude plugin install` / `uninstall` / `enable` / `disable` / `marketplace add` / `marketplace remove`) shell out to the Claude CLI; Claude owns the plugin state under `~/.claude/plugins/` (`$CLAUDE_CONFIG_DIR/plugins/` when that env var is set); NBI reads the same location.

> **Blast radius.** Force-off only kills the _management UI_ — already-installed plugins keep loading inside Claude Code sessions because Claude's plugin loader doesn't consult NBI's policy. To stop existing plugins from loading, you'd need to remove them on disk or disable them via the `claude plugin disable` CLI before flipping the policy. Force-off prevents user-driven add/remove/enable/disable through NBI; that's the contract.

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
21 changes: 20 additions & 1 deletion notebook_intelligence/claude_mcp_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
- Local scope: ``~/.claude.json`` → ``projects.<cwd>.mcpServers``
- Project scope: ``<cwd>/.mcp.json``

When ``CLAUDE_CONFIG_DIR`` is set, the CLI relocates ``.claude.json`` to
``$CLAUDE_CONFIG_DIR/.claude.json`` and the reads follow it there.

Writes go through ``claude mcp add`` / ``claude mcp remove`` so Claude
remains the source of truth for any side effects (project-trust prompts,
oauth bookkeeping, etc.). The file-based reads are decoupled from the CLI's
Expand All @@ -21,6 +24,7 @@
import dataclasses
import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Literal, Optional
Expand Down Expand Up @@ -127,6 +131,21 @@ def _gather_from_dict(
return out


def _claude_user_config_path() -> Path:
"""Locate the CLI's ``.claude.json``.

Unlike skills and transcripts, the default lives in the home dir itself
rather than inside ``~/.claude``, so this can't reuse
``util.get_claude_config_dir``'s fallback: the CLI keeps the file at
``$HOME/.claude.json`` normally but relocates it to
``$CLAUDE_CONFIG_DIR/.claude.json`` when the override is set.
"""
override = os.environ.get("CLAUDE_CONFIG_DIR")
if override:
return Path(override) / ".claude.json"
return Path.home() / ".claude.json"


class ClaudeMCPManager:
# Class-level so the lock is shared across all ClaudeMCPManager instances
# within this process. Handlers construct a fresh manager per request,
Expand All @@ -142,7 +161,7 @@ def __init__(
stdio_command_allowlist: Optional[Iterable[str]] = None,
):
self._working_dir = Path(working_dir) if working_dir else Path.cwd()
self._user_config_path = Path.home() / ".claude.json"
self._user_config_path = _claude_user_config_path()
# An empty allowlist means "no enforcement"; the default keeps
# per-user deployments permissive. Admins thread their pinned
# regex list in via ``stdio_command_allowlist``.
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
Loading
Loading