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);