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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user