From 1fa7dee863a90707976cd2a3c713dd92e71e5b42 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 17:00:06 +0200 Subject: [PATCH] feat(claude): spawn hosted sessions under the bound account's CLAUDE_CONFIG_DIR (T-484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 1 + .pql/changelog/tickets/2026-06.sql | 39 +++++++++++++++++++ lib/builtin/claude/src/agent_bootstrap.dart | 24 +++++++++++- lib/builtin/claude/src/extension.dart | 6 ++- .../claude/src/session_orchestrator.dart | 10 ++++- test/builtin/claude/agent_bootstrap_test.dart | 36 +++++++++++++++++ 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 915c0dc6..225c5405 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index c85c7193..be56501e 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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'': ` 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=` — 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); diff --git a/lib/builtin/claude/src/agent_bootstrap.dart b/lib/builtin/claude/src/agent_bootstrap.dart index 01603327..ae7e0fbc 100644 --- a/lib/builtin/claude/src/agent_bootstrap.dart +++ b/lib/builtin/claude/src/agent_bootstrap.dart @@ -74,6 +74,21 @@ Map 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 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? base}) { +AgentBootstrap agentBootstrap(String workspaceRoot, {Map? 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? 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) { diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 266d114b..9df011c6 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -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 diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index bfa7b394..eb0803ae 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -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 = {}; @@ -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'), diff --git a/test/builtin/claude/agent_bootstrap_test.dart b/test/builtin/claude/agent_bootstrap_test.dart index 337f35eb..56edf121 100644 --- a/test/builtin/claude/agent_bootstrap_test.dart +++ b/test/builtin/claude/agent_bootstrap_test.dart @@ -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'); + }); }); }