From 34b9690005e40d74b036d34c53663e9f7774e1ef Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 27 Jun 2026 21:18:49 +0200 Subject: [PATCH] feat(claude): respawn panes on account set/unset + safe --purge (T-480 part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-06.sql | 76 ++++++++++++++++++ .pql/changelog/tickets/2026-06.sql | 79 +++++++++++++++++++ CHANGELOG.md | 5 ++ lib/builtin/claude/src/account_registry.dart | 12 +++ lib/builtin/claude/src/extension.dart | 37 +++++++++ .../claude/src/session_orchestrator.dart | 25 ++++++ lib/src/daemon/claude_account_commands.dart | 6 +- .../builtin/claude/account_registry_test.dart | 12 +++ .../claude/session_orchestrator_test.dart | 14 ++++ test/daemon/claude_account_commands_test.dart | 2 +- 10 files changed, 265 insertions(+), 3 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index a65342fe..e952db08 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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 [--dir ]` — register an account. Default dir `~/.claude-/`. 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: , detected: [...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered). +- `clide claude account login ` — spawn `CLAUDE_CONFIG_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 ` — 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 `` 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 [--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 `/ide/.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 [--dir ]` — register an account. Default dir `~/.claude-/`. 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: , detected: [...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered). +- `clide claude account login ` — spawn `CLAUDE_CONFIG_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 ` — 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 `` 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 [--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 `/ide/.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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 80a15698..886971fc 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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= 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 [--dir ]` — register an account. Default dir `~/.claude-/`. 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: , detected: [...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered). +- `clide claude account login ` — spawn `CLAUDE_CONFIG_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 ` — 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 `` 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 [--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 `/ide/.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 [--dir ]` — register an account. Default dir `~/.claude-/`. 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: , detected: [...]}`. `boundAccount` is for the current workspace; `detected` is the T-477 probe output (existing `~/.claude-*/` dirs not yet registered). +- `clide claude account login ` — spawn `CLAUDE_CONFIG_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 ` — 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 `` 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 [--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 `/ide/.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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 3381234e..adf5df3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/account_registry.dart b/lib/builtin/claude/src/account_registry.dart index 2f8a42c2..1e5745db 100644 --- a/lib/builtin/claude/src/account_registry.dart +++ b/lib/builtin/claude/src/account_registry.dart @@ -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) diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 9df011c6..19b86cb8 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -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= 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 _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 diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index eb0803ae..0b6ea1ce 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -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 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 diff --git a/lib/src/daemon/claude_account_commands.dart b/lib/src/daemon/claude_account_commands.dart index d49d52d7..09c50893 100644 --- a/lib/src/daemon/claude_account_commands.dart +++ b/lib/src/daemon/claude_account_commands.dart @@ -114,13 +114,15 @@ Future _dispatch(IpcRequest req, AccountStore? store, MessagePublis case 'remove': if (name == null || name.isEmpty) return _err(req.id, 'account remove requires a '); - 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': diff --git a/test/builtin/claude/account_registry_test.dart b/test/builtin/claude/account_registry_test.dart index 9bd84bd5..a598a847 100644 --- a/test/builtin/claude/account_registry_test.dart +++ b/test/builtin/claude/account_registry_test.dart @@ -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 + }); + }); } diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index 631a1556..72bced7d 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -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. diff --git a/test/daemon/claude_account_commands_test.dart b/test/daemon/claude_account_commands_test.dart index 5da1d90e..269062e3 100644 --- a/test/daemon/claude_account_commands_test.dart +++ b/test/daemon/claude_account_commands_test.dart @@ -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 {