feat(claude): spawn hosted sessions under the bound account's CLAUDE_CONFIG_DIR (T-484)
The load-bearing piece of the multi-account epic (T-476): a bound workspace's hosted claude now spawns with CLAUDE_CONFIG_DIR set to that account's dir, so it runs under the bound account end-to-end. Every hosted session (primary / secondary / fork / teammate) inherits it — the orchestrator already routes all spawns through agentBootstrap. - New pure resolver claudeConfigDirForWorkspace(cwd, boundConfigDir, env): bound account dir > parent CLAUDE_CONFIG_DIR (respect the launcher) > null (Claude defaults to ~/.claude). The registry is injected as a plain lookup so agent_bootstrap stays Flutter-free (its tests run under `dart test`). - agentBootstrap merges CLAUDE_CONFIG_DIR BEFORE base, so an explicit SpawnSpec.env override still wins (override > binding > parent > unset); the key is omitted entirely when the resolver returns null. - Orchestrator carries an optional AccountRegistry; the claude extension builds it from ctx.settings. Null in tests → no injection (unchanged behaviour). No way to SET a binding yet (that's the CLI T-480 / settings UI T-482), so no changelog entry — the mechanism is in place, the surface lands next. Unit tests cover the resolver's four states and the envDelta precedence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,21 @@ Map<String, String> agentEnvDelta({required String workspaceRoot, required Strin
|
||||
return delta;
|
||||
}
|
||||
|
||||
/// Resolve the `CLAUDE_CONFIG_DIR` a session in [cwd] should run under (T-484,
|
||||
/// epic T-476): the bound account's dir when the workspace is bound, else the
|
||||
/// parent's `CLAUDE_CONFIG_DIR` when the launcher already set one, else null
|
||||
/// (Claude defaults to `~/.claude`).
|
||||
///
|
||||
/// Pure: the AccountRegistry is injected as a plain [boundConfigDir] lookup
|
||||
/// (workspace → bound config dir, or null) so this stays Flutter-free — the
|
||||
/// registry itself lives behind a ChangeNotifier the orchestrator owns.
|
||||
String? claudeConfigDirForWorkspace({required String cwd, required String? Function(String cwd) boundConfigDir, required Map<String, String> env}) {
|
||||
final bound = boundConfigDir(cwd);
|
||||
if (bound != null && bound.isNotEmpty) return bound;
|
||||
final inherited = env['CLAUDE_CONFIG_DIR'];
|
||||
return (inherited != null && inherited.isNotEmpty) ? inherited : null;
|
||||
}
|
||||
|
||||
/// Locate the directory to prepend to a hosted agent's PATH so `clide`
|
||||
/// resolves (T-215). Returns null when `clide` is ALREADY on [currentPath]
|
||||
/// (the installed case — T-211 drops it in `~/.local/bin`, normally already
|
||||
@@ -107,7 +122,7 @@ class AgentBootstrap {
|
||||
/// env (usually null → inherit clide's). The returned [AgentBootstrap.extraArgs]
|
||||
/// carries the context note; team callers append their own preamble and the
|
||||
/// orchestrator merges both into one `--append-system-prompt`.
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base, String? Function(String cwd)? boundConfigDir}) {
|
||||
final home = Platform.environment['HOME'];
|
||||
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
|
||||
// shells out to — find user-installed components on a desktop launch, not just
|
||||
@@ -120,7 +135,12 @@ AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base})
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
final delta = agentEnvDelta(workspaceRoot: workspaceRoot, socketPath: workspaceSocketPath(workspaceRoot), currentPath: currentPath, clideCliDir: cliDir);
|
||||
return AgentBootstrap(envDelta: {...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
|
||||
// Per-repo Claude account (T-484): a bound workspace runs claude under that
|
||||
// account's CLAUDE_CONFIG_DIR. Spread BEFORE base so an explicit per-call
|
||||
// SpawnSpec.env override still wins (precedence: override > binding > parent
|
||||
// env > unset); omitted entirely when there's nothing to set.
|
||||
final configDir = claudeConfigDirForWorkspace(cwd: workspaceRoot, boundConfigDir: boundConfigDir ?? (_) => null, env: Platform.environment);
|
||||
return AgentBootstrap(envDelta: {'CLAUDE_CONFIG_DIR': ?configDir, ...?base, ...delta}, extraArgs: ['--allowedTools', clideBashAllowRule]);
|
||||
}
|
||||
|
||||
bool _isExecutableFile(String path) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
||||
@@ -467,8 +468,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
}
|
||||
|
||||
// The clide-managed session set (T-169). Panes spawn/bind through it so a
|
||||
// session outlives its pane and is shared across surfaces.
|
||||
_orchestrator = ClaudeSessionOrchestrator();
|
||||
// session outlives its pane and is shared across surfaces. The account
|
||||
// registry (T-476) lets a bound workspace spawn under its own Claude account.
|
||||
_orchestrator = ClaudeSessionOrchestrator(accountRegistry: AccountRegistry(ctx.settings));
|
||||
activeSessionOrchestrator = _orchestrator;
|
||||
|
||||
// An in-place workspace switch (Open Project/Folder) must not leave the
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/account_registry.dart';
|
||||
import 'package:clide/builtin/claude/src/agent_bootstrap.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/session_naming.dart';
|
||||
@@ -152,10 +153,15 @@ class ManagedSession {
|
||||
ClaudeSessionOrchestrator? activeSessionOrchestrator;
|
||||
|
||||
class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude {
|
||||
ClaudeSessionOrchestrator({ProcessFactory? processFactory, this.accountRegistry}) : _factory = processFactory ?? _spawnClaude {
|
||||
_chatModel = TeamChatModel(broker: broker, sessionResolver: (name) => byMemberName(name)?.session);
|
||||
}
|
||||
|
||||
/// Per-repo Claude account bindings (epic T-476). When a workspace is bound,
|
||||
/// its hosted sessions spawn under that account's CLAUDE_CONFIG_DIR (T-484).
|
||||
/// Null in tests / when no registry is wired → no injection.
|
||||
final AccountRegistry? accountRegistry;
|
||||
|
||||
final ProcessFactory _factory;
|
||||
final _sessions = <String, ManagedSession>{};
|
||||
|
||||
@@ -244,7 +250,7 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
mcpServers.add(TeamMcpServer(broker: broker, memberId: spec.id));
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env, boundConfigDir: (cwd) => accountRegistry?.accountForWorkspace(cwd)?.dir);
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
|
||||
Reference in New Issue
Block a user