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
@@ -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':