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:
2026-06-27 17:00:06 +02:00
co-authored by Claude Opus 4.8
parent a8d321f344
commit 1fa7dee863
6 changed files with 110 additions and 6 deletions
@@ -6788,3 +6788,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM6QCYT7JBF352YVJ48', 'status', 'in_progress', 'done', NULL, '2026-06-27 13:51:01', '2026-06-27 13:51:01.108', '2026-06-27 13:51:01.108', NULL, 'd87bdd8b0216a2362c9c41d9f5fa0afa', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 14:40:34', '2026-06-27 14:40:34.929', '2026-06-27 14:40:34.929', NULL, '72f31963c666be8d4891f4886c4ddc3f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49VYMPF18PYQXD9PCMHN8', 'status', 'in_progress', 'done', NULL, '2026-06-27 14:42:39', '2026-06-27 14:42:39.553', '2026-06-27 14:42:39.553', NULL, 'fcd6750756465c07eb8a568ef7c26362', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', 'status', 'backlog', 'done', NULL, '2026-06-27 14:59:54', '2026-06-27 14:59:54.883', '2026-06-27 14:59:54.883', NULL, '84fd321fa07b452d52be53a28e433d9a', 2) ON CONFLICT(hash) DO NOTHING;
+39
View File
@@ -8580,3 +8580,42 @@ All persistence flows through SettingsStore so the existing `.clide/settings.yam
- T-481 (UI) renders registry + binding state; calls the CRUD via T-480.
- T-482 (settings UI) schema-driven rows hit these keys.
', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 14:42:39.552', NULL, '74c4a4292386cf5b311a4475551a65e1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FFW49W3V175EM4F8ZHC6JFM4', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: spawn-time CLAUDE_CONFIG_DIR injection', 'The load-bearing piece of the multi-account epic (T-476): get the right `CLAUDE_CONFIG_DIR` into the hosted Claude process''s environment. Once this is in, a workspace bound to an account spawns its `claude` under that account end-to-end.
## Where
One resolver + one hook pattern follows `lib/src/env/shell_env.dart` (single source of truth for env-related concerns).
### New resolver
`String? claudeConfigDirForWorkspace(String cwd)`:
1. Look up the binding via the T-477 `AccountRegistry`.
2. If bound return the account''s `configDir`.
3. If unbound return `Platform.environment[''CLAUDE_CONFIG_DIR'']` when the parent already set it (respect launcher choice); otherwise `null` (Claude defaults to `~/.claude`).
Flutter-free, pure (registry injected), unit-tested.
### Injection point
`lib/builtin/claude/src/agent_bootstrap.dart`''s `agentBootstrap(workspaceRoot, base:)`: when the resolver returns non-null, merge `''CLAUDE_CONFIG_DIR'': <path>` into the returned `envDelta` BEFORE the `base` spread, so `SpawnSpec.env` keeps its per-call-override precedence (line 67 of `session_orchestrator.dart` already passes the override as `base`).
Every hosted session (primary / secondary / fork / teammate) inherits the binding because `ClaudeSessionOrchestrator._spawn` already routes through `agentBootstrap` at `session_orchestrator.dart:247` no per-call-site work.
## Acceptance
1. A bound workspace spawns `claude` with `CLAUDE_CONFIG_DIR=<account-dir>` verifiable by `ps eww` / `lsof` against the spawned process.
2. An unbound workspace spawns identically to today no `CLAUDE_CONFIG_DIR` injected unless the parent env already had one.
3. `SpawnSpec.env` per-call override still wins (precedence: explicit override > workspace binding > parent env > unset).
4. The resolver returns `null` for unbound workspaces with no parent env; the bootstrap then omits the key (NOT writes an empty value).
5. Unit tests: resolver returns each of the four states; `envDelta` precedence is correct; agent_bootstrap unit tests already cover the merge shape extend them.
## Depends on
- T-477 (storage). The resolver needs `AccountRegistry.accountForWorkspace(cwd)`.
## Out of scope
- Restarting an already-running session on binding change that lives in T-480''s `set` verb (it owns the close-then-spawn flow).
- IDE bridge discovery for the bound account T-479.
', 'done', 'medium', NULL, NULL, NULL, '2026-06-25 09:16:42', '2026-06-27 14:59:54.882', NULL, 'e66f082fc7dc8dcca681eaa62be4e507', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+22 -2
View File
@@ -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) {
+4 -2
View File
@@ -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'),
@@ -83,6 +83,32 @@ void main() {
});
});
group('claudeConfigDirForWorkspace (T-484)', () {
test('bound workspace → the account config dir', () {
expect(claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => '/home/u/.claude-work', env: const {}), '/home/u/.claude-work');
});
test('unbound + parent CLAUDE_CONFIG_DIR set → respects the launcher choice', () {
expect(
claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => null, env: const {'CLAUDE_CONFIG_DIR': '/home/u/.claude-personal'}),
'/home/u/.claude-personal',
);
});
test('unbound + no parent env → null (Claude defaults to ~/.claude)', () {
expect(claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => null, env: const {}), isNull);
});
test('binding beats the parent env (binding > parent)', () {
expect(claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => '/bound', env: const {'CLAUDE_CONFIG_DIR': '/parent'}), '/bound');
});
test('an empty bound dir is ignored (falls through to parent / null)', () {
expect(claudeConfigDirForWorkspace(cwd: '/r', boundConfigDir: (_) => '', env: const {'CLAUDE_CONFIG_DIR': '/parent'}), '/parent');
expect(claudeConfigDirForWorkspace(cwd: '/r', boundConfigDir: (_) => '', env: const {}), isNull);
});
});
group('agentBootstrap (IO wrapper)', () {
test('socket in the delta matches workspaceSocketPath; allow rule in extraArgs', () {
final b = agentBootstrap('/some/workspace');
@@ -96,5 +122,15 @@ void main() {
expect(b.envDelta['FOO'], 'bar');
expect(b.envDelta['CLIDE_WORKSPACE'], '/ws');
});
test('injects the bound workspace CLAUDE_CONFIG_DIR (T-484)', () {
final b = agentBootstrap('/ws', boundConfigDir: (_) => '/home/u/.claude-work');
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/home/u/.claude-work');
});
test('an explicit base CLAUDE_CONFIG_DIR override beats the workspace binding (T-484)', () {
final b = agentBootstrap('/ws', base: {'CLAUDE_CONFIG_DIR': '/override'}, boundConfigDir: (_) => '/bound');
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/override');
});
});
}