From f813fd4d5a12a0669d242f54d0e527faa8281235 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 7 Jun 2026 12:02:14 +0200 Subject: [PATCH] rebind the Claude pane on an in-place workspace switch (T-269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate clide windows are isolated (own process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows were already fine. But switching the workspace in place (Open Project / Open Folder) only emitted ProjectOpened — nothing rebound the Claude session, so the primary pane kept the PREVIOUS repo's conversation. Two compounding causes, fixed in layers: - ClaudeSessionOrchestrator.spawn() was idempotent on the literal key 'primary' without checking cwd, so it handed the old repo's session to the new repo. It now reuses a cached session only when its cwd matches the spec; a mismatch tears the stale one down and spawns fresh. - The primary ClaudePane is built once behind a GlobalKey and spawns once, so it never re-resolved. It now listens for ProjectOpened and rebinds: close its orchestrator entry, drop the cached session id + repo root, and respawn against the now-active repo. Secondaries don't self-rebind. - ClaudeSessionHost drops the old repo's secondary/fork tabs on a switch, so a switched workspace starts like a fresh launch (lone primary). - The extension closes any remaining sessions whose cwd != the new root, catching team/non-pane sessions no pane owns. Tested at the orchestrator: cwd-aware idempotency (reuse on cwd match, teardown + respawn on mismatch). Pane/host widget coverage is intentionally deferred — claude_pane.dart has no widget-test harness yet and pulling it into coverage piecemeal would drop the gate; tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .pql/changelog/ticket_history/2026-06.sql | 2 + .pql/changelog/tickets/2026-06.sql | 24 ++++++++++++ CHANGELOG.md | 5 +++ lib/builtin/claude/src/claude_pane.dart | 39 +++++++++++++++++++ .../claude/src/claude_session_host.dart | 32 +++++++++++++++ lib/builtin/claude/src/extension.dart | 26 +++++++++++++ .../claude/src/session_orchestrator.dart | 15 +++++-- .../claude/session_lifecycle_test.dart | 38 +++++++++++++++++- 8 files changed, 176 insertions(+), 5 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 442fd956..459cfda8 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -764,3 +764,5 @@ Refs: D-78 (interaction zone / display-only conversation widgets). Built on T-16 SEQUENCING UPDATE (after T-266 refinement): the shared container/holder primitive now lives in T-266 and is CONSUMED by T-264 (nested agent run), so T-266 is no longer "last" — its primitive lands before/with T-264. T-264 is now blocked by T-266. Revised order: T-262 / T-263 (fold success result, fold agent prompt) → T-266 (shared holder/container primitive + activity-card restyle) → T-264 (nest the whole agent run on that primitive) → T-265 (relabel sidechain prose) can land anytime alongside.', NULL, '2026-06-07 08:49:16', '2026-06-07 08:49:16', '2026-06-07 08:49:16', NULL, '7795b980e57c7bf096b495b3f61c8289', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-255', 'status', 'in_progress', 'done', NULL, '2026-06-07 08:51:08', '2026-06-07 08:51:08', '2026-06-07 08:51:08', NULL, '68cf2ebb539ca57c1c8165acecf3c111', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-268', 'status', 'backlog', 'done', NULL, '2026-06-07 09:36:59', '2026-06-07 09:36:59', '2026-06-07 09:36:59', NULL, 'dcce34ebf831a8836fe31bcad6bab92c', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'status', 'backlog', 'done', NULL, '2026-06-07 09:51:32', '2026-06-07 09:51:32', '2026-06-07 09:51:32', NULL, '3fec60e8de272979ad791c313eac76f8', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'status', 'backlog', 'ready', NULL, '2026-06-07 10:03:36', '2026-06-07 10:03:36', '2026-06-07 10:03:36', NULL, '643716825d71326f6066fb04cbb67077', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 2bc7983a..2d4ea7a5 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -1597,3 +1597,27 @@ Fix: /clear in the PRIMARY pane should empty the deterministic session in place INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-268', 'bug', NULL, 'primary /clear must clear the deterministic session in place, not orphan to a fresh id', 'The primary Claude pane is anchored to a deterministic session id derived from the repo path (primarySessionId), so it resumes the same transcript across restarts (D-77/T-146). But clide-owned /clear (T-156) respawns on a fresh RANDOM id (freshSessionId). Next clide launch recomputes the deterministic id, finds its old transcript on disk, and resumes the PRE-clear conversation — the cleared session is orphaned. Continuity appears broken. Fix: /clear in the PRIMARY pane should empty the deterministic session in place — delete its .jsonl (and sidecar / dir) so ''claude --session-id'' re-creates it empty, then respawn on the SAME deterministic id. Secondary panes are throwaway and keep the fresh-random behavior. Factor the transcript path/dir construction (duplicated in claude_pane._spawn and _resumeFlow) into session_naming helpers (claudeProjectDir/claudeTranscriptPath/clearSessionTranscript) so the clear logic is DRY and unit-testable.', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:35:03', '2026-06-07 09:36:59', NULL, '824de390dc6da465575d998291976377', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions. + +Two compounding causes: +1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) — it returns the old repo''s session for the new repo. +2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) — so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation. + +Fix (T-269): +- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh. +- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind. +- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary — a switched workspace starts like a fresh launch. +- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'backlog', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:44:17', NULL, 'e2da4b11d8f64cfeddeab64f6407004a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-269', 'bug', NULL, 'primary Claude pane keeps the previous repo''s session after an in-place workspace switch', 'Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine. The bug is the in-place workspace switch (Open Project / Open Folder -> kernel.project.open, app.dart:456/474): it only emits ProjectOpened and updates settings (project.dart:147) — nothing tears down Claude sessions. + +Two compounding causes: +1. ClaudeSessionOrchestrator.spawn() is idempotent on the literal key ''primary'' WITHOUT checking cwd (session_orchestrator.dart:192-194) — it returns the old repo''s session for the new repo. +2. ClaudePane spawns once behind a _spawned guard, under a GlobalKey host (claude_pane.dart:140, extension.dart:33/52) — so after an in-place switch it never re-resolves; it keeps the previous repo''s session/conversation. + +Fix (T-269): +- Orchestrator safety net: spawn() must reuse an existing session only when its cwd matches spec.cwd; a cwd mismatch is a stale cross-workspace binding -> close + spawn fresh. +- ClaudePane (primary) listens for ProjectOpened and rebinds: close its orch entry, null out _sessionId/_repoRoot, respawn for the new repo. Secondaries do not self-rebind. +- ClaudeSessionHost listens for ProjectOpened and drops secondary/fork tabs (the old repo''s), keeping just the primary — a switched workspace starts like a fresh launch. +- Extension tears down all sessions whose cwd != new root on ProjectOpened (catches team/non-pane sessions).', 'done', 'high', NULL, NULL, 'D-77', '2026-06-07 09:44:17', '2026-06-07 09:51:32', NULL, '9b92ed68f5c352d5461c01d7e98c0b50', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-48', 'story', NULL, 'application menu bar (Zed-style)', 'Add a Zed-style application menu integrated into the hat bar: File, Edit, Selection, View, Help/About. Rendered as custom Flutter widgets (no native menu bar — we own the chrome per D-7). Menus expose the same commands registered in the command palette with their keybindings shown inline. Submenus for View should include panel toggles, zoom, focus mode. About opens a modal with version, license, and links.', 'ready', 'medium', NULL, NULL, NULL, '2026-04-23 20:30:09', '2026-06-07 10:03:36', NULL, 'e09873c62cfab523b2b4048d9681c9ae', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-270', 'story', NULL, 'make sidebar filter boxes addressable via MessageBus (clide ui filter)', NULL, 'backlog', 'medium', NULL, NULL, 'D-6', '2026-06-07 10:23:13', '2026-06-07 10:23:13', NULL, '0d79acd088da9d0b240ee5473c3a21c4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 f3ac42bf..d50fa675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Fixed +- Switching the workspace in place (Open Project/Folder) now rebinds the Claude + pane to the new repo's session instead of keeping the previous repo's + conversation, and drops the old repo's secondary tabs. Separate windows were + already isolated — this only affected reusing one window for another repo. + (T-269) - `/clear` in the primary Claude pane now clears that session **in place** — it empties the pane's deterministic, restart-stable session instead of starting a throwaway random one. Previously a cleared primary was orphaned: diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 18be7750..8f40fd1c 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -67,6 +67,7 @@ class ClaudePane extends StatefulWidget { class _ClaudePaneState extends State { StreamSubscription? _statusSub; + StreamSubscription? _projectSub; ConversationController? _conversation; StreamJsonSession? _session; SessionStatus _status = const SessionStatus(); @@ -145,12 +146,19 @@ class _ClaudePaneState extends State { unawaited(activeClaudeConfig?.ensureProbe()); // Reflect skills/config changes in the status line (T-154). activeClaudeConfig?.addListener(_onConfigChanged); + // Rebind to the new repo's session when the workspace is switched in + // place (Open Project/Folder). This pane is built once behind a + // GlobalKey and spawns once, so without this it would keep the previous + // repo's session after a switch (T-269). + _projectSub = ClideKernel.of(context).events.on().listen(_onProjectChanged); } } @override void dispose() { activeClaudeConfig?.removeListener(_onConfigChanged); + _projectSub?.cancel(); + _projectSub = null; _statusSub?.cancel(); _statusSub = null; // The orchestrator owns the session, so disposing this pane does NOT kill @@ -185,6 +193,37 @@ class _ClaudePaneState extends State { return _spawn(); } + /// Rebind to the active workspace when the project is switched in place + /// (T-269). Only the primary rebinds — secondaries/forks belong to the old + /// repo and are dropped by the host. A no-op when the path is unchanged or + /// the session hasn't resolved its repo yet. + void _onProjectChanged(ProjectOpened e) { + if (!mounted || !widget.isPrimary) return; + if (_repoRoot == null || e.path == _repoRoot) return; + unawaited(_rebindToActiveProject()); + } + + /// Tear down the current session and respawn against the now-active + /// workspace: drop the cached session id and repo root so [_spawn] + /// re-resolves both for the new repo (T-269). + Future _rebindToActiveProject() async { + _statusSub?.cancel(); + _statusSub = null; + await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session + _conversation = null; + _session = null; + _sessionId = null; + _repoRoot = null; + if (mounted) { + setState(() { + _status = const SessionStatus(); + _error = null; + _statusLine = 'starting…'; + }); + } + await _spawn(); + } + Future _spawn() async { if (!mounted) return; final ipc = _ipc(); diff --git a/lib/builtin/claude/src/claude_session_host.dart b/lib/builtin/claude/src/claude_session_host.dart index c59194da..e8b2251d 100644 --- a/lib/builtin/claude/src/claude_session_host.dart +++ b/lib/builtin/claude/src/claude_session_host.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -21,6 +24,9 @@ class ClaudeSessionHostState extends State { late final MultitabController<_Session> _controller; int _nextSecondary = 1; + StreamSubscription? _projectSub; + String? _projectRoot; + @override void initState() { super.initState(); @@ -39,8 +45,34 @@ class ClaudeSessionHostState extends State { ); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _projectSub ??= ClideKernel.of(context).events.on().listen(_onProjectChanged); + } + + /// Reset to a lone primary tab when the workspace is switched in place + /// (T-269): the old repo's secondaries/forks don't belong in the new + /// workspace. Removing them disposes their panes, which close their sessions + /// through the orchestrator. The primary tab stays and rebinds itself. + void _onProjectChanged(ProjectOpened e) { + final prev = _projectRoot; + _projectRoot = e.path; + if (prev == null || prev == e.path) return; // initial open / no change + if (!mounted) return; + final stale = _controller.entries.where((x) => x.id != _primaryId).map((x) => x.id).toList(); + if (stale.isEmpty) return; + setState(() { + for (final id in stale) { + _controller.remove(id); + } + _nextSecondary = 1; + }); + } + @override void dispose() { + _projectSub?.cancel(); _controller.dispose(); super.dispose(); } diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index 0670d6dc..c1d8cd0d 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -36,6 +36,10 @@ class ClaudeExtension extends ClideExtension { ClaudeSessionOrchestrator? _orchestrator; final List> _subs = []; + /// Active workspace root, tracked so an in-place project switch can tear + /// down the previous repo's sessions (T-269). + String? _projectRoot; + /// App-wide Claude environment (skills, commands, settings, permissions, /// slash list). Built and loaded at activation (D-76, T-151). ClaudeConfig? get config => _config; @@ -324,12 +328,34 @@ class ClaudeExtension extends ClideExtension { _orchestrator = ClaudeSessionOrchestrator(); activeSessionOrchestrator = _orchestrator; + // An in-place workspace switch (Open Project/Folder) must not leave the + // previous repo's sessions running — including team/non-pane sessions the + // panes don't own. Close every session that doesn't belong to the new + // root; panes rebind themselves to the new repo (T-269). + _subs.add(ctx.events.on().listen(_onProjectChanged)); + // `clide image show ` (T-249): the dispatcher resolves + publishes an // 'image' message; we inject the matching card into the conversation the // user is looking at (the primary lead, else the first visible session). _subs.add(ctx.messages.subscribe(channel: imageShowChannel).listen(_onImageShow)); } + /// Close every session that doesn't belong to the newly-active workspace + /// after an in-place project switch (T-269). The initial open (no previous + /// root) and a no-op re-open are skipped. Panes rebind to the new repo on + /// their own; this catches team/orphan sessions no pane owns. + void _onProjectChanged(ProjectOpened e) { + final prev = _projectRoot; + _projectRoot = e.path; + if (prev == null || prev == e.path) return; + final orch = _orchestrator; + if (orch == null) return; + final stale = orch.sessions.where((m) => m.cwd != e.path).map((m) => m.id).toList(); + for (final id in stale) { + unawaited(orch.close(id)); + } + } + /// Inject an [ImageMessage] from a published `image` bus message (T-249). /// Dropped silently if no live conversation is available — the CLI already /// reported success at publish time, and a missing pane is transient. diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index 848df01d..c5028ec3 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -187,11 +187,20 @@ class ClaudeSessionOrchestrator extends ChangeNotifier { ManagedSession? byId(String id) => _sessions[id]; - /// Spawn and register a session. Idempotent on [SpawnSpec.id] — a repeat - /// call returns the existing session rather than starting a second process. + /// Spawn and register a session. Idempotent on [SpawnSpec.id] *within a + /// workspace* — a repeat call for the same [SpawnSpec.cwd] returns the + /// existing session rather than starting a second process (the fast path that + /// lets a hidden/kept-alive pane keep its session). A repeat call with the + /// SAME id but a DIFFERENT cwd means the workspace was switched in place + /// (T-269): the existing session belongs to the old repo, so it is torn down + /// and a fresh one spawned for the new repo — a pane must never inherit + /// another workspace's conversation. Future spawn(SpawnSpec spec) async { final existing = _sessions[spec.id]; - if (existing != null) return existing; + if (existing != null) { + if (existing.cwd == spec.cwd) return existing; + await close(spec.id); + } // Team sessions host the clide-team MCP server and get a roster + role // injected into their system prompt (T-170). Register the member before diff --git a/test/builtin/claude/session_lifecycle_test.dart b/test/builtin/claude/session_lifecycle_test.dart index 3ec5d14e..cb9f8a77 100644 --- a/test/builtin/claude/session_lifecycle_test.dart +++ b/test/builtin/claude/session_lifecycle_test.dart @@ -47,11 +47,11 @@ ClaudeSessionOrchestrator _orch(List<_FakeProc> created) { ); } -SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath}) => SpawnSpec( +SpawnSpec _spec(String id, {bool resume = false, String? transcriptPath, String cwd = '/repo'}) => SpawnSpec( id: id, role: id, sessionId: '$id-uuid', - cwd: '/repo', + cwd: cwd, resume: resume, transcriptPath: transcriptPath, ); @@ -140,6 +140,40 @@ void main() { }); }); + // ---- in-place workspace switch (T-269) --------------------------------- + + group('spawn — cwd-aware idempotency (T-269)', () { + late List<_FakeProc> created; + late ClaudeSessionOrchestrator orch; + + setUp(() { + created = []; + orch = _orch(created); + }); + + tearDown(() => orch.dispose()); + + test('same id + same cwd reuses the session (no second process)', () async { + final a = await orch.spawn(_spec('primary', cwd: '/repo-a')); + final b = await orch.spawn(_spec('primary', cwd: '/repo-a')); + expect(identical(a, b), isTrue); + expect(created, hasLength(1)); + }); + + test('same id + different cwd tears down the old session and spawns fresh', () async { + // Workspace switched in place: the new repo must NOT inherit the old + // repo's 'primary' session. + final a = await orch.spawn(_spec('primary', cwd: '/repo-a')); + final b = await orch.spawn(_spec('primary', cwd: '/repo-b')); + expect(identical(a, b), isFalse); + expect(b.cwd, '/repo-b'); + expect(created, hasLength(2)); + expect(created.first.killed, isTrue, reason: 'old repo session is killed'); + expect(orch.sessions, hasLength(1)); + expect(orch.byId('primary')!.cwd, '/repo-b'); + }); + }); + // ---- /resume — bind to an existing session via orchestrator ------------- group('/resume — resume an existing session via orchestrator', () {