add ClaudeConfig service — layered Claude env + version-keyed slash probe
Builtin-owned, app-wide source of truth for Claude Code's environment (D-76): skills, custom commands, settings, and permission rules read from ~/.claude and the repo's .claude, layered local-over-global, watched for changes. Built-in slash commands come from the stream-json `init` event, captured by a one-turn probe cached in clide's own dir keyed on the resolved claude version — so it runs at most once per claude version per machine. load() stays cheap (version + cache-read + disk + watch); the paid probe is a lazy ensureProbe() consumers call on first need, so app-init and tests never pay for a model turn. Wired into the Claude extension lifecycle and exposed as a builtin singleton. T-151. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
/// ClaudeConfig (T-151, D-76): builtin-owned, app-wide source of truth for
|
||||
/// Claude Code's environment — skills, custom slash commands, settings, and
|
||||
/// permission rules — read from the GLOBAL (`~/.claude`) and LOCAL (`.claude`)
|
||||
/// scopes and layered local-over-global.
|
||||
///
|
||||
/// Built-in slash commands aren't on disk; they come from a one-shot
|
||||
/// stream-json `init` probe of the `claude` CLI. The probe costs one minimal
|
||||
/// turn, so its result is cached in clide's OWN global dir keyed on the
|
||||
/// resolved claude version — it runs at most once per claude version per
|
||||
/// machine, shared across every clide instance (the data is claude-locked,
|
||||
/// not workspace-locked). We read Claude's config but never write into
|
||||
/// `~/.claude` (same boundary as pql's data, D-3).
|
||||
///
|
||||
/// Consumers (composer typeahead, status pane) read from here; none re-scan
|
||||
/// the filesystem or re-derive the command list.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/files/ignore.dart';
|
||||
import 'package:clide/src/files/watcher.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// The live, app-wide instance once the Claude extension has activated
|
||||
/// (builtin-owned singleton, D-76). Null before activation and in tests that
|
||||
/// don't wire it. Consumers should accept an injected [ClaudeConfig] for
|
||||
/// testability and fall back to this in production.
|
||||
ClaudeConfig? activeClaudeConfig;
|
||||
|
||||
enum ConfigScope { global, local }
|
||||
|
||||
@immutable
|
||||
class ClaudeSkill {
|
||||
const ClaudeSkill({required this.name, this.description, required this.scope});
|
||||
final String name;
|
||||
final String? description;
|
||||
final ConfigScope scope;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ClaudeCommand {
|
||||
const ClaudeCommand({required this.name, required this.scope});
|
||||
final String name;
|
||||
final ConfigScope scope;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ClaudePermissions {
|
||||
const ClaudePermissions({this.allow = const [], this.deny = const [], this.ask = const []});
|
||||
final List<String> allow;
|
||||
final List<String> deny;
|
||||
final List<String> ask;
|
||||
bool get isEmpty => allow.isEmpty && deny.isEmpty && ask.isEmpty;
|
||||
}
|
||||
|
||||
/// The slice of session metadata the stream-json `init` event carries that
|
||||
/// isn't derivable from disk: the full slash-command list (built-ins + custom
|
||||
/// + plugin + MCP), the skill names, the default model and permission mode.
|
||||
@immutable
|
||||
class ClaudeProbe {
|
||||
const ClaudeProbe({
|
||||
required this.version,
|
||||
required this.slashCommands,
|
||||
required this.skills,
|
||||
this.model,
|
||||
this.permissionMode,
|
||||
});
|
||||
|
||||
final String version;
|
||||
final List<String> slashCommands;
|
||||
final List<String> skills;
|
||||
final String? model;
|
||||
final String? permissionMode;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'version': version,
|
||||
'slash_commands': slashCommands,
|
||||
'skills': skills,
|
||||
if (model != null) 'model': model,
|
||||
if (permissionMode != null) 'permission_mode': permissionMode,
|
||||
};
|
||||
|
||||
/// Build from a stream-json `init` event object. Returns null if it doesn't
|
||||
/// look like an init event (no version field).
|
||||
static ClaudeProbe? fromInitEvent(Map<String, Object?> j, {required String version}) {
|
||||
if (j['slash_commands'] == null && j['claude_code_version'] == null) return null;
|
||||
return ClaudeProbe(
|
||||
version: version,
|
||||
slashCommands: _stringList(j['slash_commands']),
|
||||
skills: _stringList(j['skills']),
|
||||
model: j['model'] as String?,
|
||||
permissionMode: j['permissionMode'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static ClaudeProbe fromCache(Map<String, Object?> j) => ClaudeProbe(
|
||||
version: (j['version'] as String?) ?? '',
|
||||
slashCommands: _stringList(j['slash_commands']),
|
||||
skills: _stringList(j['skills']),
|
||||
model: j['model'] as String?,
|
||||
permissionMode: j['permission_mode'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? v) => v is List ? v.whereType<String>().toList(growable: false) : const [];
|
||||
|
||||
/// Resolves the installed claude version string (e.g. "2.1.150 (Claude
|
||||
/// Code)"), or null if `claude` can't be run.
|
||||
typedef ClaudeVersionRunner = Future<String?> Function();
|
||||
|
||||
/// Runs the one-shot init probe and returns its raw stream-json stdout, or
|
||||
/// null on failure.
|
||||
typedef ClaudeInitProbe = Future<String?> Function();
|
||||
|
||||
/// Returns a change stream for [dir] (fires on any file event under it).
|
||||
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
|
||||
|
||||
/// Modest version-agnostic fallback used when the probe is unavailable, so
|
||||
/// the typeahead still offers the common built-ins.
|
||||
const List<String> kFallbackSlashCommands = [
|
||||
'add-dir',
|
||||
'agents',
|
||||
'clear',
|
||||
'compact',
|
||||
'config',
|
||||
'context',
|
||||
'cost',
|
||||
'doctor',
|
||||
'exit',
|
||||
'help',
|
||||
'init',
|
||||
'mcp',
|
||||
'memory',
|
||||
'model',
|
||||
'permissions',
|
||||
'resume',
|
||||
'review',
|
||||
'status',
|
||||
'usage',
|
||||
];
|
||||
|
||||
class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudeConfig({
|
||||
required Directory globalDir,
|
||||
required Directory cacheDir,
|
||||
Directory? projectDir,
|
||||
ClaudeVersionRunner? versionRunner,
|
||||
ClaudeInitProbe? initProbe,
|
||||
ClaudeConfigWatch? watch,
|
||||
Duration debounce = const Duration(milliseconds: 150),
|
||||
}) : _globalDir = globalDir,
|
||||
_cacheDir = cacheDir,
|
||||
_projectDir = projectDir,
|
||||
_versionRunner = versionRunner ?? _defaultVersionRunner,
|
||||
_initProbe = initProbe ?? _defaultInitProbe,
|
||||
_watch = watch,
|
||||
_debounceFor = debounce;
|
||||
|
||||
final Directory _globalDir;
|
||||
final Directory _cacheDir;
|
||||
Directory? _projectDir;
|
||||
final ClaudeVersionRunner _versionRunner;
|
||||
final ClaudeInitProbe _initProbe;
|
||||
final ClaudeConfigWatch? _watch;
|
||||
final Duration _debounceFor;
|
||||
|
||||
String? _version;
|
||||
ClaudeProbe? _probe;
|
||||
bool _probing = false;
|
||||
List<ClaudeSkill> _skills = const [];
|
||||
List<ClaudeCommand> _commands = const [];
|
||||
Map<String, Object?> _settings = const {};
|
||||
ClaudePermissions _permissions = const ClaudePermissions();
|
||||
String? _error;
|
||||
|
||||
final List<FileWatcher> _watchers = [];
|
||||
final List<StreamSubscription<void>> _subs = [];
|
||||
Timer? _debounce;
|
||||
|
||||
// ---- Public, listenable views -------------------------------------------
|
||||
|
||||
/// Resolved claude version (e.g. "2.1.150"), or null if claude is missing.
|
||||
String? get version => _version;
|
||||
|
||||
/// True once a claude version resolved — the healthcheck signal.
|
||||
bool get ready => _version != null;
|
||||
|
||||
/// Last error encountered resolving the environment, if any.
|
||||
String? get error => _error;
|
||||
|
||||
ClaudeProbe? get probe => _probe;
|
||||
|
||||
/// All slash commands for the typeahead — the probe's authoritative list
|
||||
/// (built-ins + custom + plugin + MCP), or the static fallback.
|
||||
List<String> get slashCommands => _probe?.slashCommands ?? kFallbackSlashCommands;
|
||||
|
||||
List<ClaudeSkill> get skills => _skills;
|
||||
List<ClaudeCommand> get commands => _commands;
|
||||
Map<String, Object?> get settings => Map.unmodifiable(_settings);
|
||||
ClaudePermissions get permissions => _permissions;
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
/// Full load — cheap and side-effect-light: resolve the version (`claude
|
||||
/// --version`, no model turn), read the version-keyed probe cache if it
|
||||
/// already exists, read the layered disk config, and start watching. Never
|
||||
/// runs the paid probe — call [ensureProbe] for that.
|
||||
Future<void> load() async {
|
||||
_error = null;
|
||||
_version = _parseVersion(await _guard(_versionRunner));
|
||||
await _readProbeCache();
|
||||
await _loadDiskConfig();
|
||||
_startWatchers();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Run the one-turn init probe if we don't already have its data (cache
|
||||
/// miss / first use after a claude upgrade), then cache it. Idempotent and
|
||||
/// safe to call repeatedly; consumers (the slash typeahead) call it lazily
|
||||
/// on first need so app-init and tests never pay for a model turn.
|
||||
Future<void> ensureProbe() async {
|
||||
if (_probe != null || _probing) return;
|
||||
final v = _version;
|
||||
if (v == null) return;
|
||||
_probing = true;
|
||||
try {
|
||||
final probe = _parseInitProbe(await _guard(_initProbe), v);
|
||||
if (probe == null) return; // stay on the static fallback
|
||||
_probe = probe;
|
||||
await _writeProbeCache(probe);
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_probing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the on-disk config (skills/commands/settings/permissions). The
|
||||
/// watcher calls this on change; callers can force it. Version + probe are
|
||||
/// not re-resolved (the binary doesn't change under us at runtime).
|
||||
Future<void> refresh() async {
|
||||
await _loadDiskConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Point the local scope at a different workspace (on project switch). Keeps
|
||||
/// the same instance — and its listeners — re-reading disk and re-watching
|
||||
/// for the new repo. The global scope and probe are unaffected.
|
||||
Future<void> setProjectDir(Directory? dir) async {
|
||||
_stopWatching();
|
||||
_projectDir = dir;
|
||||
await _loadDiskConfig();
|
||||
_startWatchers();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopWatching();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _stopWatching() {
|
||||
_debounce?.cancel();
|
||||
_debounce = null;
|
||||
for (final s in _subs) {
|
||||
unawaited(s.cancel());
|
||||
}
|
||||
_subs.clear();
|
||||
for (final w in _watchers) {
|
||||
unawaited(w.stop());
|
||||
}
|
||||
_watchers.clear();
|
||||
}
|
||||
|
||||
// ---- Probe (version-keyed cache in clide's own dir) ---------------------
|
||||
|
||||
File get _cacheFile => File('${_cacheDir.path}/init-$_version.json');
|
||||
|
||||
/// Read the version-keyed cache if present. Read-only; no shell-out.
|
||||
Future<void> _readProbeCache() async {
|
||||
_probe = null;
|
||||
if (_version == null) return;
|
||||
final file = _cacheFile;
|
||||
if (!await file.exists()) return;
|
||||
try {
|
||||
final j = jsonDecode(await file.readAsString()) as Map<String, Object?>;
|
||||
final cached = ClaudeProbe.fromCache(j);
|
||||
if (cached.version == _version) _probe = cached;
|
||||
} catch (_) {
|
||||
// Corrupt cache — leave null; ensureProbe will re-probe on demand.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeProbeCache(ClaudeProbe probe) async {
|
||||
try {
|
||||
await _cacheDir.create(recursive: true);
|
||||
await _cacheFile.writeAsString(jsonEncode(probe.toJson()));
|
||||
} catch (_) {
|
||||
// A non-writable cache dir is non-fatal; we just re-probe next launch.
|
||||
}
|
||||
}
|
||||
|
||||
ClaudeProbe? _parseInitProbe(String? raw, String version) {
|
||||
if (raw == null) return null;
|
||||
for (final line in const LineSplitter().convert(raw)) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('{')) continue;
|
||||
Map<String, Object?> j;
|
||||
try {
|
||||
j = jsonDecode(trimmed) as Map<String, Object?>;
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (j['type'] == 'system' && j['subtype'] == 'init') {
|
||||
return ClaudeProbe.fromInitEvent(j, version: version);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Disk config (layered global -> local) ------------------------------
|
||||
|
||||
Future<void> _loadDiskConfig() async {
|
||||
final skills = <ClaudeSkill>[];
|
||||
final commands = <ClaudeCommand>[];
|
||||
final settings = <String, Object?>{};
|
||||
final allow = <String>[], deny = <String>[], ask = <String>[];
|
||||
|
||||
for (final (scope, dir) in _scopeDirs()) {
|
||||
skills.addAll(await _loadSkills(dir, scope));
|
||||
commands.addAll(await _loadCommands(dir, scope));
|
||||
final s = await _loadSettings(dir);
|
||||
settings.addAll(s); // local overrides global per top-level key
|
||||
final p = _permissionsOf(s);
|
||||
allow.addAll(p.allow);
|
||||
deny.addAll(p.deny);
|
||||
ask.addAll(p.ask);
|
||||
}
|
||||
|
||||
_skills = _dedupeByName(skills, (s) => s.name);
|
||||
_commands = _dedupeByName(commands, (c) => c.name);
|
||||
_settings = settings;
|
||||
_permissions = ClaudePermissions(allow: _uniq(allow), deny: _uniq(deny), ask: _uniq(ask));
|
||||
}
|
||||
|
||||
/// Global first so that local entries, added later, win on collisions.
|
||||
List<(ConfigScope, Directory)> _scopeDirs() {
|
||||
final pd = _projectDir;
|
||||
return [
|
||||
(ConfigScope.global, _globalDir),
|
||||
if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')),
|
||||
];
|
||||
}
|
||||
|
||||
Future<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
|
||||
final dir = Directory('${scopeDir.path}/skills');
|
||||
if (!await dir.exists()) return const [];
|
||||
final out = <ClaudeSkill>[];
|
||||
await for (final entry in dir.list()) {
|
||||
if (entry is! Directory) continue;
|
||||
final manifest = File('${entry.path}/SKILL.md');
|
||||
if (!await manifest.exists()) continue;
|
||||
final fm = _parseFrontmatter(await manifest.readAsString());
|
||||
out.add(ClaudeSkill(
|
||||
name: fm.name ?? _basename(entry.path),
|
||||
description: fm.description,
|
||||
scope: scope,
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<List<ClaudeCommand>> _loadCommands(Directory scopeDir, ConfigScope scope) async {
|
||||
final dir = Directory('${scopeDir.path}/commands');
|
||||
if (!await dir.exists()) return const [];
|
||||
final out = <ClaudeCommand>[];
|
||||
await for (final entry in dir.list()) {
|
||||
if (entry is! File || !entry.path.endsWith('.md')) continue;
|
||||
final base = _basename(entry.path);
|
||||
out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _loadSettings(Directory scopeDir) async {
|
||||
final file = File('${scopeDir.path}/settings.json');
|
||||
if (!await file.exists()) return const {};
|
||||
try {
|
||||
final j = jsonDecode(await file.readAsString());
|
||||
return j is Map ? j.map((k, v) => MapEntry('$k', v)) : const {};
|
||||
} catch (_) {
|
||||
return const {}; // a malformed settings file shouldn't sink the load
|
||||
}
|
||||
}
|
||||
|
||||
ClaudePermissions _permissionsOf(Map<String, Object?> settings) {
|
||||
final p = settings['permissions'];
|
||||
if (p is! Map) return const ClaudePermissions();
|
||||
return ClaudePermissions(
|
||||
allow: _stringList(p['allow']),
|
||||
deny: _stringList(p['deny']),
|
||||
ask: _stringList(p['ask']),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Watching -----------------------------------------------------------
|
||||
|
||||
void _startWatchers() {
|
||||
final source = _watch ?? _defaultWatch;
|
||||
for (final (_, dir) in _scopeDirs()) {
|
||||
if (!dir.existsSync()) continue;
|
||||
_subs.add(source(dir).listen((_) => _onChange()));
|
||||
}
|
||||
}
|
||||
|
||||
Stream<void> _defaultWatch(Directory dir) {
|
||||
final w = FileWatcher(root: dir, ignore: IgnoreSet.parse(const []));
|
||||
_watchers.add(w);
|
||||
unawaited(w.start());
|
||||
return w.stream.map((_) {});
|
||||
}
|
||||
|
||||
void _onChange() {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(_debounceFor, () => unawaited(refresh()));
|
||||
}
|
||||
|
||||
// ---- Helpers ------------------------------------------------------------
|
||||
|
||||
Future<String?> _guard(Future<String?> Function() f) async {
|
||||
try {
|
||||
return await f();
|
||||
} catch (e) {
|
||||
_error = '$e';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _parseVersion(String? raw) {
|
||||
if (raw == null) return null;
|
||||
final m = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(raw);
|
||||
return m?.group(1);
|
||||
}
|
||||
|
||||
static String _basename(String path) => path.split(Platform.pathSeparator).last;
|
||||
|
||||
({String? name, String? description}) _parseFrontmatter(String content) {
|
||||
final body = content.replaceFirst('\r\n', '\n');
|
||||
if (!body.startsWith('---')) return (name: null, description: null);
|
||||
final end = body.indexOf('\n---', 3);
|
||||
if (end < 0) return (name: null, description: null);
|
||||
try {
|
||||
final y = loadYaml(body.substring(3, end));
|
||||
if (y is Map) {
|
||||
return (name: y['name'] as String?, description: y['description'] as String?);
|
||||
}
|
||||
} catch (_) {
|
||||
// Unparseable frontmatter — caller falls back to the dir name.
|
||||
}
|
||||
return (name: null, description: null);
|
||||
}
|
||||
|
||||
static List<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
|
||||
final byName = <String, T>{};
|
||||
for (final item in all) {
|
||||
byName[nameOf(item)] = item; // later (local) scope wins
|
||||
}
|
||||
final out = byName.values.toList();
|
||||
out.sort((a, b) => nameOf(a).compareTo(nameOf(b)));
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<String> _uniq(List<String> xs) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final x in xs)
|
||||
if (seen.add(x)) x,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _defaultVersionRunner() async {
|
||||
try {
|
||||
final r = await Process.run('claude', ['--version']);
|
||||
return r.stdout as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _defaultInitProbe() async {
|
||||
try {
|
||||
final r = await Process.run('claude', [
|
||||
'-p',
|
||||
'.',
|
||||
'--no-session-persistence',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
]);
|
||||
return r.stdout as String?;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
@@ -25,8 +27,13 @@ class ClaudeExtension extends ClideExtension {
|
||||
final GlobalKey<ClaudeSessionHostState> _hostKey = GlobalKey();
|
||||
|
||||
TeamObserver? _observer;
|
||||
ClaudeConfig? _config;
|
||||
final List<StreamSubscription<dynamic>> _subs = [];
|
||||
|
||||
/// App-wide Claude environment (skills, commands, settings, permissions,
|
||||
/// slash list). Built and loaded at activation (D-76, T-151).
|
||||
ClaudeConfig? get config => _config;
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
TabContribution(
|
||||
@@ -65,6 +72,24 @@ class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
|
||||
// Resolve the Claude environment up front (app-init): version + the
|
||||
// version-keyed slash probe + the layered global/local config. Exposed
|
||||
// as the builtin-owned singleton so panes + the status item read one
|
||||
// source of truth (D-76, T-151). Reloaded as the workspace changes.
|
||||
final home = Platform.environment['HOME'];
|
||||
if (home != null) {
|
||||
final cfg = ClaudeConfig(
|
||||
globalDir: Directory('$home/.claude'),
|
||||
cacheDir: Directory('${ctx.settings.appDir.path}/claude'),
|
||||
projectDir: ctx.settings.projectDir,
|
||||
);
|
||||
_config = cfg;
|
||||
activeClaudeConfig = cfg;
|
||||
unawaited(cfg.load());
|
||||
_subs.add(ctx.events.on<ProjectOpened>().listen((e) => cfg.setProjectDir(Directory(e.path))));
|
||||
}
|
||||
|
||||
// Cold-start reap: kill any leftover secondary tmux sessions from
|
||||
// a previous run. D-41's "secondary numbering resets between
|
||||
// clide runs" only holds if the leftovers are gone before the new
|
||||
@@ -106,6 +131,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
_subs.clear();
|
||||
_stopObserver();
|
||||
if (identical(activeClaudeConfig, _config)) activeClaudeConfig = null;
|
||||
_config?.dispose();
|
||||
_config = null;
|
||||
// Best-effort cleanup on explicit extension teardown. The cold-
|
||||
// start reap in activate is the actual safety net.
|
||||
final primary = await _primarySessionName();
|
||||
|
||||
Reference in New Issue
Block a user