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
@@ -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 {