feat(claude): respawn panes on account set/unset + safe --purge (T-480 part 2)

The extension consumer for the per-repo account verbs, making set/unset
fully functional. The Claude extension subscribes to accountActionChannel:

- set / unset → ClaudeSessionOrchestrator.respawnForWorkspace(cwd): closes the
  workspace's solo sessions (awaiting real process death, T-437) and re-spawns
  each on the same id with --resume, so the conversation continues under the
  newly-bound CLAUDE_CONFIG_DIR (resolved at spawn by agentBootstrap). Team and
  forked sessions are skipped — re-joining the broker / re-forking on an account
  swap is out of scope; they adopt the account on their next natural spawn.
- remove --purge → deletes the config dir behind isPurgeableAccountDir, a strict
  guard that only ever removes a ~/.claude-* directory that is a direct child of
  $HOME. The purge payload now carries the dir (the account is gone from the
  registry by publish time).

login still only publishes its action — spawning the `claude login` terminal
pane needs argv+env terminal-pane support and is split to T-485.

Covered: respawnForWorkspace (respawn solo, skip fork/other-repo) and the purge
guard's accept/reject matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 21:18:49 +02:00
co-authored by Claude Opus 4.8
parent 744b6cff43
commit 34b9690005
10 changed files with 265 additions and 3 deletions
+76
View File
@@ -6790,3 +6790,79 @@ 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 ('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;
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 ('06FFW49W95HJK08BCRQSWFZBF4', 'status', 'backlog', 'in_progress', NULL, '2026-06-27 15:21:57', '2026-06-27 15:21:57.422', '2026-06-27 15:21:57.422', NULL, 'bd509581fcdeb4a965164421c8d2b094', 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 ('06FFW49W95HJK08BCRQSWFZBF4', 'description', 'The `clide claude account …` dispatcher and verbs — D-6 parity for the multi-account feature (T-476). UI tickets (T-481 / T-482) call these verbs only; no UI writes the registry directly.
## Verbs
- `clide claude account add <name> [--dir <path>]` — register an account. Default dir `~/.claude-<name>/`. Idempotent (re-add with the same args is a no-op; conflicting args returns a clear userError). Does NOT spawn `claude login` — composability with `login`.
- `clide claude account list` JSON: `{accounts: [{name, dir}], boundAccount: <name|null>, detected: [<dir>...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered).
- `clide claude account login <name>` spawn `CLAUDE_CONFIG_DIR=<dir> claude login` in a managed terminal pane (`lib/builtin/terminal/`). Claude CLI owns the OAuth browser bounce. Verb returns once the pane spawns; OAuth completion is observed by the pane lifecycle, not awaited by the verb.
- `clide claude account set <name>` bind the current workspace AND respawn the active Claude pane(s) on the new account. Respawn flow uses `ClaudeSessionOrchestrator.close` (which already awaits real process death per T-437) followed by a fresh `spawn` with the same id. Honest userError if `<name>` isn''t registered.
- `clide claude account unset` clear the binding for this workspace; respawn pane(s) on the default account. Emits a binding-removed event so T-479''s lock multiplexer can prune the matching `ide/` lock.
- `clide claude account remove <name> [--purge]` — registry-remove; refuses if any open workspace is bound to it (with a clear message). `--purge` additionally `rm -rf`s the `configDir` after registry removal (off by default; require confirmation).
## Where
- Dispatcher: new `lib/src/daemon/claude_account_commands.dart` mirror the shape of `image_commands.dart` (CommandSchema-declared positionals + flags, Flutter-free handler).
- A MessageBus channel e.g. `accountActionChannel = ''claude.account''` for non-CLI consumers (the Claude extension + UI) to observe registry/binding changes.
- Registers in `DaemonDispatcher` alongside the existing `image.show` / `pql.*` verbs.
## Acceptance
1. All six verbs round-trip through `clide capabilities` and execute against the live dispatcher.
2. `clide claude account add work` registers and persists across clide restarts.
3. `clide claude account list` returns the registry + this workspace''s binding + the detected-dirs probe results.
4. `clide claude account login work` opens a managed terminal pane in which `claude login` runs against `~/.claude-work/`; OAuth completion writes credentials into that dir.
5. `clide claude account set work` while a Claude pane is open: pane respawns with `CLAUDE_CONFIG_DIR=~/.claude-work/` (verified end-to-end against the live process).
6. `clide claude account unset` returns the pane to the default config dir; the matching `<dir>/ide/<pid>.lock` (T-479) is removed.
7. `clide claude account remove work` refuses while a workspace is bound; succeeds once unset. `--purge` removes the underlying directory after registry removal.
8. Tests follow the `image_commands` shape: schema declaration, Flutter-free handler tests, dispatcher registration check.
## Depends on
- T-477 (storage / registry).
- T-478 (env injection) so the respawn under `set`/`unset` actually changes which account the new process uses.
## Decision ref
D-6 (CLI parity).
', 'The `clide claude account ` dispatcher and verbs D-6 parity for the multi-account feature (T-476). UI tickets (T-481 / T-482) call these verbs only; no UI writes the registry directly.
## Verbs
- `clide claude account add <name> [--dir <path>]` — register an account. Default dir `~/.claude-<name>/`. Idempotent (re-add with the same args is a no-op; conflicting args returns a clear userError). Does NOT spawn `claude login` — composability with `login`.
- `clide claude account list` JSON: `{accounts: [{name, dir}], boundAccount: <name|null>, detected: [<dir>...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered).
- `clide claude account login <name>` spawn `CLAUDE_CONFIG_DIR=<dir> claude login` in a managed terminal pane (`lib/builtin/terminal/`). Claude CLI owns the OAuth browser bounce. Verb returns once the pane spawns; OAuth completion is observed by the pane lifecycle, not awaited by the verb.
- `clide claude account set <name>` bind the current workspace AND respawn the active Claude pane(s) on the new account. Respawn flow uses `ClaudeSessionOrchestrator.close` (which already awaits real process death per T-437) followed by a fresh `spawn` with the same id. Honest userError if `<name>` isn''t registered.
- `clide claude account unset` clear the binding for this workspace; respawn pane(s) on the default account. Emits a binding-removed event so T-479''s lock multiplexer can prune the matching `ide/` lock.
- `clide claude account remove <name> [--purge]` — registry-remove; refuses if any open workspace is bound to it (with a clear message). `--purge` additionally `rm -rf`s the `configDir` after registry removal (off by default; require confirmation).
## Where
- Dispatcher: new `lib/src/daemon/claude_account_commands.dart` mirror the shape of `image_commands.dart` (CommandSchema-declared positionals + flags, Flutter-free handler).
- A MessageBus channel e.g. `accountActionChannel = ''claude.account''` for non-CLI consumers (the Claude extension + UI) to observe registry/binding changes.
- Registers in `DaemonDispatcher` alongside the existing `image.show` / `pql.*` verbs.
## Acceptance
1. All six verbs round-trip through `clide capabilities` and execute against the live dispatcher.
2. `clide claude account add work` registers and persists across clide restarts.
3. `clide claude account list` returns the registry + this workspace''s binding + the detected-dirs probe results.
4. `clide claude account login work` opens a managed terminal pane in which `claude login` runs against `~/.claude-work/`; OAuth completion writes credentials into that dir.
5. `clide claude account set work` while a Claude pane is open: pane respawns with `CLAUDE_CONFIG_DIR=~/.claude-work/` (verified end-to-end against the live process).
6. `clide claude account unset` returns the pane to the default config dir; the matching `<dir>/ide/<pid>.lock` (T-479) is removed.
7. `clide claude account remove work` refuses while a workspace is bound; succeeds once unset. `--purge` removes the underlying directory after registry removal.
8. Tests follow the `image_commands` shape: schema declaration, Flutter-free handler tests, dispatcher registration check.
## Depends on
- T-477 (storage / registry).
- T-478 (env injection) so the respawn under `set`/`unset` actually changes which account the new process uses.
## Decision ref
D-6 (CLI parity).
Done: the six verbs (add/list/login/set/unset/remove) + registry persistence, plus the extension consumer set/unset respawn the workspace''s solo Claude panes onto the bound account (ClaudeSessionOrchestrator.respawnForWorkspace), remove --purge deletes the config dir behind a strict ~/.claude-* guard (isPurgeableAccountDir). The login VERB publishes its action on accountActionChannel; spawning the actual ''claude login'' terminal pane needs argv+env terminal-pane support and is split to T-485.', NULL, '2026-06-27 19:18:09', '2026-06-27 19:18:09.075', '2026-06-27 19:18:09.075', NULL, '1fa1967a8c538b0ee0622162d9730a74', 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 ('06FFW49W95HJK08BCRQSWFZBF4', 'status', 'in_progress', 'done', NULL, '2026-06-27 19:18:18', '2026-06-27 19:18:18.202', '2026-06-27 19:18:18.202', NULL, 'ebe069d240f705d5da92ebcddd1b2219', 2) ON CONFLICT(hash) DO NOTHING;
+79
View File
@@ -8656,3 +8656,82 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
D-6 (CLI parity).
', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-25 09:16:42', '2026-06-27 15:21:57.421', NULL, 'affb7565fa860ff7787c03f9f159ad8f', 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 ('06FGN13H42TP2MP3T27CTSCDRW', 'task', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'claude account login: spawn a CLAUDE_CONFIG_DIR=<dir> claude login terminal pane', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-27 19:17:59.712', '2026-06-27 19:17:59.712', NULL, 'fb93094866bf84c58520037915f5eae1', 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 ('06FFW49W95HJK08BCRQSWFZBF4', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: clide CLI verbs (account add/login/set/unset/list/remove)', 'The `clide claude account …` dispatcher and verbs — D-6 parity for the multi-account feature (T-476). UI tickets (T-481 / T-482) call these verbs only; no UI writes the registry directly.
## Verbs
- `clide claude account add <name> [--dir <path>]` — register an account. Default dir `~/.claude-<name>/`. Idempotent (re-add with the same args is a no-op; conflicting args returns a clear userError). Does NOT spawn `claude login` — composability with `login`.
- `clide claude account list` JSON: `{accounts: [{name, dir}], boundAccount: <name|null>, detected: [<dir>...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered).
- `clide claude account login <name>` spawn `CLAUDE_CONFIG_DIR=<dir> claude login` in a managed terminal pane (`lib/builtin/terminal/`). Claude CLI owns the OAuth browser bounce. Verb returns once the pane spawns; OAuth completion is observed by the pane lifecycle, not awaited by the verb.
- `clide claude account set <name>` bind the current workspace AND respawn the active Claude pane(s) on the new account. Respawn flow uses `ClaudeSessionOrchestrator.close` (which already awaits real process death per T-437) followed by a fresh `spawn` with the same id. Honest userError if `<name>` isn''t registered.
- `clide claude account unset` clear the binding for this workspace; respawn pane(s) on the default account. Emits a binding-removed event so T-479''s lock multiplexer can prune the matching `ide/` lock.
- `clide claude account remove <name> [--purge]` — registry-remove; refuses if any open workspace is bound to it (with a clear message). `--purge` additionally `rm -rf`s the `configDir` after registry removal (off by default; require confirmation).
## Where
- Dispatcher: new `lib/src/daemon/claude_account_commands.dart` mirror the shape of `image_commands.dart` (CommandSchema-declared positionals + flags, Flutter-free handler).
- A MessageBus channel e.g. `accountActionChannel = ''claude.account''` for non-CLI consumers (the Claude extension + UI) to observe registry/binding changes.
- Registers in `DaemonDispatcher` alongside the existing `image.show` / `pql.*` verbs.
## Acceptance
1. All six verbs round-trip through `clide capabilities` and execute against the live dispatcher.
2. `clide claude account add work` registers and persists across clide restarts.
3. `clide claude account list` returns the registry + this workspace''s binding + the detected-dirs probe results.
4. `clide claude account login work` opens a managed terminal pane in which `claude login` runs against `~/.claude-work/`; OAuth completion writes credentials into that dir.
5. `clide claude account set work` while a Claude pane is open: pane respawns with `CLAUDE_CONFIG_DIR=~/.claude-work/` (verified end-to-end against the live process).
6. `clide claude account unset` returns the pane to the default config dir; the matching `<dir>/ide/<pid>.lock` (T-479) is removed.
7. `clide claude account remove work` refuses while a workspace is bound; succeeds once unset. `--purge` removes the underlying directory after registry removal.
8. Tests follow the `image_commands` shape: schema declaration, Flutter-free handler tests, dispatcher registration check.
## Depends on
- T-477 (storage / registry).
- T-478 (env injection) so the respawn under `set`/`unset` actually changes which account the new process uses.
## Decision ref
D-6 (CLI parity).
Done: the six verbs (add/list/login/set/unset/remove) + registry persistence, plus the extension consumer set/unset respawn the workspace''s solo Claude panes onto the bound account (ClaudeSessionOrchestrator.respawnForWorkspace), remove --purge deletes the config dir behind a strict ~/.claude-* guard (isPurgeableAccountDir). The login VERB publishes its action on accountActionChannel; spawning the actual ''claude login'' terminal pane needs argv+env terminal-pane support and is split to T-485.', 'in_progress', 'medium', NULL, NULL, 'D-6', '2026-06-25 09:16:42', '2026-06-27 19:18:09.075', NULL, 'e0f47fcd8e842e6b26fb3598b6e292f1', 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 ('06FFW49W95HJK08BCRQSWFZBF4', 'story', '06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'Per-repo Claude account: clide CLI verbs (account add/login/set/unset/list/remove)', 'The `clide claude account …` dispatcher and verbs — D-6 parity for the multi-account feature (T-476). UI tickets (T-481 / T-482) call these verbs only; no UI writes the registry directly.
## Verbs
- `clide claude account add <name> [--dir <path>]` — register an account. Default dir `~/.claude-<name>/`. Idempotent (re-add with the same args is a no-op; conflicting args returns a clear userError). Does NOT spawn `claude login` — composability with `login`.
- `clide claude account list` JSON: `{accounts: [{name, dir}], boundAccount: <name|null>, detected: [<dir>...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered).
- `clide claude account login <name>` spawn `CLAUDE_CONFIG_DIR=<dir> claude login` in a managed terminal pane (`lib/builtin/terminal/`). Claude CLI owns the OAuth browser bounce. Verb returns once the pane spawns; OAuth completion is observed by the pane lifecycle, not awaited by the verb.
- `clide claude account set <name>` bind the current workspace AND respawn the active Claude pane(s) on the new account. Respawn flow uses `ClaudeSessionOrchestrator.close` (which already awaits real process death per T-437) followed by a fresh `spawn` with the same id. Honest userError if `<name>` isn''t registered.
- `clide claude account unset` clear the binding for this workspace; respawn pane(s) on the default account. Emits a binding-removed event so T-479''s lock multiplexer can prune the matching `ide/` lock.
- `clide claude account remove <name> [--purge]` — registry-remove; refuses if any open workspace is bound to it (with a clear message). `--purge` additionally `rm -rf`s the `configDir` after registry removal (off by default; require confirmation).
## Where
- Dispatcher: new `lib/src/daemon/claude_account_commands.dart` mirror the shape of `image_commands.dart` (CommandSchema-declared positionals + flags, Flutter-free handler).
- A MessageBus channel e.g. `accountActionChannel = ''claude.account''` for non-CLI consumers (the Claude extension + UI) to observe registry/binding changes.
- Registers in `DaemonDispatcher` alongside the existing `image.show` / `pql.*` verbs.
## Acceptance
1. All six verbs round-trip through `clide capabilities` and execute against the live dispatcher.
2. `clide claude account add work` registers and persists across clide restarts.
3. `clide claude account list` returns the registry + this workspace''s binding + the detected-dirs probe results.
4. `clide claude account login work` opens a managed terminal pane in which `claude login` runs against `~/.claude-work/`; OAuth completion writes credentials into that dir.
5. `clide claude account set work` while a Claude pane is open: pane respawns with `CLAUDE_CONFIG_DIR=~/.claude-work/` (verified end-to-end against the live process).
6. `clide claude account unset` returns the pane to the default config dir; the matching `<dir>/ide/<pid>.lock` (T-479) is removed.
7. `clide claude account remove work` refuses while a workspace is bound; succeeds once unset. `--purge` removes the underlying directory after registry removal.
8. Tests follow the `image_commands` shape: schema declaration, Flutter-free handler tests, dispatcher registration check.
## Depends on
- T-477 (storage / registry).
- T-478 (env injection) so the respawn under `set`/`unset` actually changes which account the new process uses.
## Decision ref
D-6 (CLI parity).
Done: the six verbs (add/list/login/set/unset/remove) + registry persistence, plus the extension consumer set/unset respawn the workspace''s solo Claude panes onto the bound account (ClaudeSessionOrchestrator.respawnForWorkspace), remove --purge deletes the config dir behind a strict ~/.claude-* guard (isPurgeableAccountDir). The login VERB publishes its action on accountActionChannel; spawning the actual ''claude login'' terminal pane needs argv+env terminal-pane support and is split to T-485.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-25 09:16:42', '2026-06-27 19:18:18.202', NULL, 'b598d17e51e2349e18fa8ad0c906389a', 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);
+5
View File
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Per-repo Claude accounts — `clide claude account` verbs.** Manage named
Claude config dirs and bind one per workspace: `add`/`list`/`set`/`unset`/
`remove [--purge]`. `set`/`unset` respawn the workspace's Claude pane(s) onto
the bound account (resuming the conversation under its `CLAUDE_CONFIG_DIR`).
(T-480, epic T-476)
- **`clide instances` / `clide instance` CLI verbs.** `instances` lists every
live clide on the machine with its version, pid, workspace, and socket path
(probing the runtime dir); `instance` reports the one you're connected to — so
@@ -153,6 +153,18 @@ class AccountRegistry {
}
}
/// Whether [dir] is safe to `rm -rf` as a purged account config dir
/// (`remove --purge`, T-480): it must be a `~/.claude-*` directory that is a
/// DIRECT child of [home]. Anything else — an absolute path elsewhere, a nested
/// path, the real `~/.claude` — is rejected even though the path came from our
/// own registry. A wrong recursive delete is unrecoverable, so the predicate
/// is deliberately strict.
bool isPurgeableAccountDir(String dir, String home) {
if (home.isEmpty) return false;
final base = dir.split('/').last;
return dir == '$home/$base' && base.startsWith('.claude-');
}
/// Bootstrap probe (T-483): existing `~/.claude-*` directories that look like a
/// Claude config dir (have a `.claude.json` file or a `sessions/` dir), as
/// adoption candidates. Pure read — mutates nothing; the welcome view (T-481)
+37
View File
@@ -17,6 +17,7 @@ import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortL
import 'package:clide/builtin/claude/src/session_storage.dart';
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
import 'package:clide/src/daemon/claude_account_commands.dart' show accountActionChannel;
import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel;
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatPane;
import 'package:clide/builtin/claude/src/team_panel_host.dart';
@@ -487,6 +488,42 @@ class ClaudeExtension extends ClideExtension {
// A sidebar "pick up" click (T-327) publishes the full ticket; inject it
// into the active conversation as a user turn so Claude starts working it.
_subs.add(ctx.messages.subscribe(publisher: 'builtin.tickets', channel: 'pick-up').listen(_onTicketPickUp));
// `clide claude account set/unset/remove --purge` (T-480): the dispatcher
// writes the registry then publishes here; only the UI layer can respawn
// the workspace's panes onto the newly-bound account or delete a config dir.
_subs.add(ctx.messages.subscribe(channel: accountActionChannel).listen(_onAccountAction));
}
/// Side-effects for the `claude account` verbs (T-480). The dispatcher does
/// the registry write and publishes the action here; respawning panes,
/// deleting a config dir, and (future) the login terminal pane are UI-layer
/// concerns the Flutter-free handler can't do itself.
void _onAccountAction(Message m) {
switch (m.data['action'] as String?) {
case 'set':
case 'unset':
final cwd = m.data['cwd'] as String?;
final orch = _orchestrator;
if (cwd != null && orch != null) unawaited(orch.respawnForWorkspace(cwd));
case 'purge':
final dir = m.data['dir'] as String?;
if (dir != null) unawaited(_purgeAccountDir(dir));
// 'login' would spawn a `CLAUDE_CONFIG_DIR=<dir> claude login` terminal
// pane; that needs argv+env pane support in the terminal builtin and is
// wired separately. The action is published for that consumer.
}
}
/// Delete a purged account's config dir (`remove --purge`). Guarded: only a
/// `~/.claude-*` directory that is a direct child of the user's home is ever
/// removed — never an arbitrary path, even though the dir came from our own
/// registry. A `rm -rf` of the wrong dir is unrecoverable.
Future<void> _purgeAccountDir(String dir) async {
final home = Platform.environment['HOME'];
if (home == null || !isPurgeableAccountDir(dir, home)) return;
final d = Directory(dir);
if (await d.exists()) await d.delete(recursive: true);
}
/// Hand a picked-up ticket to the active Claude session (T-327/T-339). The
@@ -318,6 +318,31 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
notifyListeners();
}
/// Respawn the workspace's solo sessions in place so they pick up a changed
/// per-repo Claude account (T-480). Each is closed (awaits real process
/// death, T-437) then re-spawned on the SAME id with `--resume` of its real
/// session id, so the conversation continues under the newly-bound
/// `CLAUDE_CONFIG_DIR` (resolved at spawn time by [agentBootstrap] from the
/// [accountRegistry]). Team / forked sessions are skipped — re-joining the
/// broker or re-forking on an account swap is out of scope; they adopt the
/// new account on their next natural spawn.
Future<void> respawnForWorkspace(String cwd) async {
final targets = _sessions.values.where((s) => s.cwd == cwd && s.memberName == null && s.forkSourceSessionId == null).toList();
for (final s in targets) {
final spec = SpawnSpec(
id: s.id,
role: s.role,
sessionId: s.sessionId,
cwd: s.cwd,
resume: true,
transcriptPath: claudeTranscriptPath(s.cwd, s.sessionId),
visible: s.visible,
);
await close(s.id);
await spawn(spec);
}
}
/// Kill and forget a session (the real teardown). The conversation's
/// onDispose kills the process + closes its streams; we then AWAIT the
/// session's teardown so the `claude` process is genuinely dead before we
+4 -2
View File
@@ -114,13 +114,15 @@ Future<IpcResponse> _dispatch(IpcRequest req, AccountStore? store, MessagePublis
case 'remove':
if (name == null || name.isEmpty) return _err(req.id, 'account remove requires a <name>');
if (_byName(store, name) == null) return _err(req.id, 'no such account: "$name"');
final removing = _byName(store, name);
if (removing == null) return _err(req.id, 'no such account: "$name"');
if (store.boundAccountNames().contains(name)) {
return _err(req.id, 'account "$name" is bound to a workspace', hint: 'clide claude account unset (in that workspace) first');
}
await store.remove(name);
// The dir delete is IO the extension owns (this handler is Flutter-free).
if (purge) publish?.call('cli', accountActionChannel, {'action': 'purge', 'name': name});
// Carry the dir in the payload — the account is gone from the registry now.
if (purge) publish?.call('cli', accountActionChannel, {'action': 'purge', 'name': name, 'dir': removing.dir});
return _ok(req.id, {'removed': name, 'purge': purge});
case 'set':
@@ -137,4 +137,16 @@ void main() {
expect(probeExistingAccountDirs('/no/such/home/${DateTime.now().microsecondsSinceEpoch}'), isEmpty);
});
});
group('purge guard (T-480)', () {
const home = '/home/u';
test('accepts ~/.claude-* direct children only, rejects everything else', () {
expect(isPurgeableAccountDir('/home/u/.claude-work', home), isTrue);
expect(isPurgeableAccountDir('/home/u/.claude', home), isFalse); // the real config dir
expect(isPurgeableAccountDir('/home/u/projects', home), isFalse); // not .claude-*
expect(isPurgeableAccountDir('/home/u/sub/.claude-work', home), isFalse); // nested, not a direct child
expect(isPurgeableAccountDir('/etc/.claude-work', home), isFalse); // elsewhere on disk
expect(isPurgeableAccountDir('/home/u/.claude-work', ''), isFalse); // no home → never delete
});
});
}
@@ -76,6 +76,20 @@ void main() {
expect(created, hasLength(1));
});
test('respawnForWorkspace respawns solo workspace sessions with --resume, skips forks + other repos (T-480)', () async {
await orch.spawn(spec('primary')); // solo /repo → respawn
await orch.spawn(SpawnSpec(id: 'fork', role: 'fork', sessionId: 'fork-uuid', cwd: '/repo', forkSourceSessionId: 'primary-uuid')); // fork → skip
await orch.spawn(SpawnSpec(id: 'other', role: 'other', sessionId: 'other-uuid', cwd: '/elsewhere')); // other repo → skip
final before = created.length; // 3
await orch.respawnForWorkspace('/repo');
expect(created.first.killed, isTrue, reason: "the primary's original process is killed");
expect(created.length, before + 1, reason: 'exactly one session respawned');
expect(spawnedArgs.last, contains('--resume'));
expect(orch.sessions.map((s) => s.id), unorderedEquals(['primary', 'fork', 'other']));
});
// T-374: spawn() check-then-acts across awaits; without the in-flight
// map, two CONCURRENT spawns both passed the registry check and the
// loser's live claude process was orphaned.
@@ -127,7 +127,7 @@ void main() {
final r = await run(['remove', 'work'], flags: {'purge': true});
expect(r.ok, isTrue, reason: r.error?.message);
expect(store.accounts, isEmpty);
expect(published.single.data, {'action': 'purge', 'name': 'work'});
expect(published.single.data, {'action': 'purge', 'name': 'work', 'dir': '/home/u/.claude-work'});
});
test('login publishes a login action with the dir; unknown account errors', () async {