wire the Claude pane onto the session orchestrator (T-169)

The pane no longer spawns/owns its StreamJsonSession — it spawns-or-binds
through the app-wide ClaudeSessionOrchestrator by a stable pane key, and
the orchestrator owns the session + conversation. Consequences: disposing
a pane no longer kills its session (a kept-alive/hidden pane keeps it);
the primary re-binds to its live session on remount (conversation
survives); closing a secondary tab closes that session; /clear and
/resume close + respawn through the orchestrator. The extension owns the
orchestrator (set on activate, disposed on deactivate).

Remaining for T-169: re-point TeamObserver from tmux-polling to
orchestrating managed sessions, and roster-driven show/hide.

T-169.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 13:24:19 +02:00
co-authored by Claude Opus 4.7
parent a79a78fc8f
commit e0fa081cb9
3 changed files with 46 additions and 15 deletions
+31 -15
View File
@@ -15,6 +15,7 @@ import 'conversation_view.dart';
import 'prompt_card.dart';
import 'session_index.dart';
import 'session_naming.dart';
import 'session_orchestrator.dart';
import 'session_picker.dart';
import 'slash_commands.dart';
import 'stream_json_session.dart';
@@ -64,6 +65,9 @@ class _ClaudePaneState extends State<ClaudePane> {
bool _spawned = false;
/// This pane's stable key in the session orchestrator (T-169).
String get _orchId => widget.isPrimary ? 'primary' : 'secondary-${widget.secondaryIndex}';
// The status line surfaced to the bottom status bar via ClidePane — the
// live session fields (model/mode/context, T-150) plus the configured
// skills count from ClaudeConfig (T-154). Null when there's nothing yet.
@@ -109,8 +113,12 @@ class _ClaudePaneState extends State<ClaudePane> {
activeClaudeConfig?.removeListener(_onConfigChanged);
_statusSub?.cancel();
_statusSub = null;
// The controller's onDispose kills the session (process + streams).
_conversation?.dispose();
// The orchestrator owns the session, so disposing this pane does NOT kill
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
// A secondary tab being *closed* is a real teardown, so close its session;
// the primary persists (its deterministic id resumes next launch), and the
// orchestrator disposes everything on extension teardown.
if (!widget.isPrimary) unawaited(activeSessionOrchestrator?.close(_orchId));
_conversation = null;
_session = null;
super.dispose();
@@ -160,24 +168,32 @@ class _ClaudePaneState extends State<ClaudePane> {
final home = Platform.environment['HOME'] ?? '';
final transcriptFile = '$home/.claude/projects/${repoRoot.replaceAll('/', '-')}/$_sessionId.jsonl';
final resume = await File(transcriptFile).exists();
final sessionArgs = claudeLaunchArgs(_sessionId!, resume: resume);
final StreamJsonSession session;
// The orchestrator owns the session (T-169): spawn-or-bind by our pane key,
// so the session (and its accumulating conversation) outlives this pane.
final orch = activeSessionOrchestrator;
if (orch == null) {
setState(() => _error = 'Session orchestrator unavailable.');
return;
}
final ManagedSession managed;
try {
final proc = await ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: repoRoot);
session = StreamJsonSession(proc)..start();
managed = await orch.spawn(SpawnSpec(
id: _orchId,
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
sessionId: _sessionId!,
cwd: repoRoot,
resume: resume,
));
} catch (e) {
if (mounted) setState(() => _error = 'Could not start claude: $e');
return;
}
if (!mounted) {
await session.dispose();
return;
}
if (!mounted) return;
_session = session;
_conversation = ConversationController(stream: session.items, onDispose: session.dispose);
_statusSub = session.statusStream.listen((s) {
_session = managed.session;
_conversation = managed.conversation;
_statusSub = managed.session.statusStream.listen((s) {
if (!mounted) return;
setState(() => _status = s);
});
@@ -229,11 +245,11 @@ class _ClaudePaneState extends State<ClaudePane> {
}
/// Tear the current session down and respawn bound to [sessionId]. The old
/// process is killed; its transcript stays on disk (history preserved).
/// process is killed via the orchestrator; its transcript stays on disk.
Future<void> _respawnWithSession(String sessionId) async {
_statusSub?.cancel();
_statusSub = null;
_conversation?.dispose(); // onDispose kills the old session
await activeSessionOrchestrator?.close(_orchId); // kills the old session
_conversation = null;
_session = null;
_sessionId = sessionId;
+10
View File
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
import 'package:clide/builtin/claude/src/claude_config.dart';
import 'package:clide/builtin/claude/src/claude_session_host.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/pane_context_status.dart';
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
import 'package:clide/builtin/claude/src/session_index.dart';
@@ -32,6 +33,7 @@ class ClaudeExtension extends ClideExtension {
TeamObserver? _observer;
ClaudeConfig? _config;
ClaudeSessionOrchestrator? _orchestrator;
final List<StreamSubscription<dynamic>> _subs = [];
/// App-wide Claude environment (skills, commands, settings, permissions,
@@ -110,6 +112,11 @@ class ClaudeExtension extends ClideExtension {
_subs.add(ctx.events.on<ProjectOpened>().listen((e) => cfg.setProjectDir(Directory(e.path))));
}
// The clide-managed session set (T-169). Panes spawn/bind through it so a
// session outlives its pane and is shared across surfaces.
_orchestrator = ClaudeSessionOrchestrator();
activeSessionOrchestrator = _orchestrator;
// Cold-start reap: kill any leftover secondary tmux sessions from
// a previous run. D-41's "secondary numbering resets between
// clide runs" only holds if the leftovers are gone before the new
@@ -151,6 +158,9 @@ class ClaudeExtension extends ClideExtension {
}
_subs.clear();
_stopObserver();
if (identical(activeSessionOrchestrator, _orchestrator)) activeSessionOrchestrator = null;
_orchestrator?.dispose();
_orchestrator = null;
if (identical(activeClaudeConfig, _config)) activeClaudeConfig = null;
_config?.dispose();
_config = null;
@@ -74,6 +74,11 @@ class ManagedSession {
bool visible;
}
/// App-wide orchestrator, set by the Claude extension on activate (like
/// `activeClaudeConfig`). Panes spawn/bind their session through it so the
/// session set is shared across panes, the cockpit, and team tiles.
ClaudeSessionOrchestrator? activeSessionOrchestrator;
class ClaudeSessionOrchestrator extends ChangeNotifier {
ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude;