From a79a78fc8fa6fe35e9e1710f25c0b9766a94cef1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 25 May 2026 12:52:04 +0200 Subject: [PATCH] add the clide-managed session orchestrator (T-169 core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaudeSessionOrchestrator owns a registry of ManagedSessions, decoupling a session's lifecycle from any pane: spawn() starts + registers a stream-json process, show()/hide() toggle visibility WITHOUT killing the process, and close() tears it down. This is the one primitive Phase 2's teammate / secondary-tab / forked-branch panes all become (D-77). The process factory is injectable so the lifecycle is unit-tested without a real claude. Not yet wired into the pane — that re-pointing is the next T-169 step. T-169. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 1 + .../claude/src/session_orchestrator.dart | 152 ++++++++++++++++++ .../claude/session_orchestrator_test.dart | 92 +++++++++++ 4 files changed, 246 insertions(+) create mode 100644 lib/builtin/claude/src/session_orchestrator.dart create mode 100644 test/builtin/claude/session_orchestrator_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index f0a9f79f..f411f04a 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2240,3 +2240,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, 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-183', 'description', 'A comprehensive read view (with edit affordances where sensible) of the Claude environment, as the Config sub-tab of the sidebar (T-182). Sources: ClaudeConfig (D-76 / T-151, already reads skills, commands, settings, permissions from ~/.claude + .claude layered) plus the stream-json init/initialize payloads (agents, output styles + available styles, MCP servers, models, plugins). Sections: SKILLS, AGENTS, COMMANDS, PERMISSIONS (allow/ask/deny, colour-coded), HOOKS, MCP SERVERS, SETTINGS (model, output style). Show local+global layering (local wins) and that it is watched/live. Covers the interaction-type surfaces documented on anthropic.com so the user can see everything configured for Claude in one place. Acceptance: each section lists its entries from ClaudeConfig + init data; permissions colour-coded by rule kind; layering + source shown; updates live on a watched .claude change; widget tests. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182 (sub-tab shell).', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', NULL, '2026-05-25 09:31:37', '2026-05-25 09:31:37', '2026-05-25 09:31:37', NULL, '473e88ee9fa895f5dc30fcac5b001fa6', 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-183', 'description', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', NULL, '2026-05-25 09:35:47', '2026-05-25 09:35:47', '2026-05-25 09:35:47', NULL, '33086bf1d766da9bb8e12ddeeaa8ed92', 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-182', 'description', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view. Default to Activity. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; existing stats/team content slots into Activity/Team unchanged; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', NULL, '2026-05-25 09:38:15', '2026-05-25 09:38:15', '2026-05-25 09:38:15', NULL, 'a41f10a11412d0f7ee0cd5e0e7700488', 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-169', 'status', 'backlog', 'in_progress', NULL, '2026-05-25 10:49:30', '2026-05-25 10:49:30', '2026-05-25 10:49:30', NULL, '3269bcb75eb13dffc13be53c9f71ca73', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index c4f876f8..46e79b96 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2758,3 +2758,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, 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-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Each category is an EXPANDABLE accordion section (no ellipsis truncation): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS, MCP SERVERS, SETTINGS, each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what that skill/agent/command says. Non-file entries render inline: permissions (allow/ask/deny, colour-coded), MCP servers, model, output style; SETTINGS can open settings.json. Show local+global layering (local wins) and that it is watched/live. Acceptance: sections expand/collapse to the full list; clicking a file-backed item opens its .md in the reader; permissions colour-coded by rule kind; updates live on a watched .claude change; widget tests for expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-25 09:31:37', NULL, '2011edc54063ca86e8418fdb0d4a81ac', 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-183', 'task', 'T-164', 'Claude Config sidebar tab — skills/agents/commands/settings/permissions/hooks/MCP', 'The Config sub-tab (T-182): a browsable view of the Claude environment over ClaudeConfig (D-76 / T-151 — skills, commands, settings, permissions from ~/.claude + .claude, layered, local wins) plus the stream-json init/initialize payloads (agents, output styles, MCP servers, models, plugins). Layout: SETTINGS is a FIXED key->value table pinned at the top (not collapsible) — model, output style, permission mode, source. Everything variable-length is an EXPANDABLE accordion section showing the FULL list when expanded, never truncated (truncating to first-N + ellipsis falsely prioritises the first entries and makes the list pointless): SKILLS, AGENTS, COMMANDS, HOOKS, PERMISSIONS (grouped allow/ask/deny, colour-coded, each rule on its own line), MCP SERVERS — each with a count. File-backed entries are CLICKABLE: a skill (SKILL.md), agent (.claude/agents/*.md), or command (.claude/commands/*.md) — clicking fires an open-file action into the right-side markdown reader/editor pane (reusing the existing file-open plumbing, D-6 parity) to show what it says. Watched/live on .claude changes. Acceptance: settings render as a top table; every variable section expands to its complete list (no ellipsis); clicking a file-backed item opens its .md in the reader; permissions colour-coded by kind; widget tests for table + expand + click-to-open. Wireframe: docs/design/wireframes/claude-prompts/09-sidebar-config-tab.png. Blocked by T-182.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-25 09:26:40', '2026-05-25 09:35:47', NULL, 'a4962b8a69cffe93b72275d098251e1c', 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-182', 'task', 'T-164', 'Claude sidebar sub-tabs: Activity / Team / Config', 'The Claude meta-sidebar is overloaded (roster + tasks + messages + stats + config do not fit one scroll). Split it into a sub-tab strip (reuse the search-panel mode-strip pattern) with three tabs: (1) Activity — stats + live runtime visibility (T-141/T-157); (2) Team — roster + tasks + message-feed widget (T-171 + T-180), mostly hidden when solo and auto-fronted when a team is live; (3) Config — the Claude-environment view (T-183). Default to Activity. SHARED GEOMETRY: Activity values and the Config settings table use the SAME two-column label->value table (same column x + row pitch + section-header style) so toggling between tabs does not visually jump. Acceptance: a sub-tab strip switches the sidebar body between the three surfaces; Activity + Config settings render on the shared table so toggling is seamless; Team auto-fronts on team spawn; widget tests for tab switching. Wireframes: docs/design/wireframes/claude-prompts/07-sidebar-activity-tab.png, 08-sidebar-team-tab.png, 09-sidebar-config-tab.png. Relates to T-141, T-157, T-171, T-180.', 'backlog', 'medium', NULL, NULL, 'D-77', '2026-05-25 09:26:31', '2026-05-25 09:38:15', NULL, 'de0ab01119b20a1ef66ecba1821cdcd4', 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-169', 'task', 'T-164', 'P2: session orchestrator — one managed-session primitive (spawn/show/hide)', 'Generalize the Claude pane into a clide-managed SESSION primitive: clide spawns/owns each stream-json Claude process; a pane is just a view on a session; teammate / secondary tab / forked branch / inline subagent are all the same thing. Provide spawn / show / hide / close over the set of live sessions; visibility is a UI toggle, not a lifecycle change (sessions stay live when hidden). Re-point the T-139 TeamObserver from observing a Claude tmux team to ORCHESTRATING clide-managed sessions; the T-140 tiles render whatever sessions are shown. Acceptance: multiple managed sessions run concurrently; each can be shown/hidden as a pane from a roster without tearing down its process; tests for the orchestrator lifecycle. Blocked by T-165 (phase 1 transport).', 'in_progress', 'medium', NULL, NULL, 'D-77', '2026-05-24 16:26:49', '2026-05-25 10:49:30', NULL, '698c3527c5cdfba6a19b51fee6cc3cbc', 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/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart new file mode 100644 index 00000000..f3762ff1 --- /dev/null +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -0,0 +1,152 @@ +/// Owns the set of clide-managed Claude sessions (T-169, D-77). +/// +/// A session is a `claude` stream-json process clide spawns and renders; a +/// pane is just a *view* on one. The orchestrator decouples a session's +/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide] +/// toggle visibility WITHOUT tearing the process down, and [close] kills it. +/// This is the one primitive behind teammate / secondary tab / forked branch +/// (Phase 2): they are all just managed sessions shown as panes. +/// +/// The process factory is injectable so tests drive the lifecycle without +/// spawning a real `claude`. +library; + +import 'dart:async'; + +import 'package:clide/builtin/claude/src/conversation_controller.dart'; +import 'package:clide/builtin/claude/src/session_naming.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart'; +import 'package:flutter/foundation.dart'; + +/// Creates the subprocess for a session — production uses +/// [ClaudeStreamJsonProcess.start]; tests inject a fake. +typedef ProcessFactory = Future Function({ + required List sessionArgs, + required String cwd, + Map? env, +}); + +/// What to spawn. [id] is the orchestrator's stable key (e.g. `primary`, +/// `teammate:tyre`); [sessionId] is claude's `--session-id`. +class SpawnSpec { + const SpawnSpec({ + required this.id, + required this.role, + required this.sessionId, + required this.cwd, + this.resume = false, + this.env, + this.visible = true, + }); + + final String id; + final String role; + final String sessionId; + final String cwd; + + /// Resume an existing session (`--resume`) vs create one (`--session-id`). + final bool resume; + final Map? env; + final bool visible; +} + +/// One clide-managed session: the process wrapper plus the conversation it +/// feeds. Owned by the orchestrator, not by any pane — so hiding a pane +/// leaves it (and its accumulating conversation) intact. +class ManagedSession { + ManagedSession({ + required this.id, + required this.role, + required this.sessionId, + required this.session, + required this.conversation, + this.visible = true, + }); + + final String id; + final String role; + final String sessionId; + final StreamJsonSession session; + final ConversationController conversation; + + /// Whether a pane is currently showing this session. A view toggle only — + /// the process stays alive when hidden. + bool visible; +} + +class ClaudeSessionOrchestrator extends ChangeNotifier { + ClaudeSessionOrchestrator({ProcessFactory? processFactory}) : _factory = processFactory ?? _spawnClaude; + + final ProcessFactory _factory; + final _sessions = {}; + + static Future _spawnClaude({required List sessionArgs, required String cwd, Map? env}) => + ClaudeStreamJsonProcess.start(sessionArgs: sessionArgs, cwd: cwd, env: env); + + /// All managed sessions, in insertion order. + List get sessions => List.unmodifiable(_sessions.values); + + /// Just the sessions a pane should currently render. + List get visibleSessions => [ + for (final m in _sessions.values) + if (m.visible) m, + ]; + + 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. + Future spawn(SpawnSpec spec) async { + final existing = _sessions[spec.id]; + if (existing != null) return existing; + + final proc = await _factory( + sessionArgs: claudeLaunchArgs(spec.sessionId, resume: spec.resume), + cwd: spec.cwd, + env: spec.env, + ); + final session = StreamJsonSession(proc)..start(); + final conversation = ConversationController(stream: session.items, onDispose: session.dispose); + final managed = ManagedSession( + id: spec.id, + role: spec.role, + sessionId: spec.sessionId, + session: session, + conversation: conversation, + visible: spec.visible, + ); + _sessions[spec.id] = managed; + notifyListeners(); + return managed; + } + + /// Show / hide a session as a pane — a visibility toggle only; the process + /// keeps running while hidden. + void show(String id) => _setVisible(id, true); + void hide(String id) => _setVisible(id, false); + + void _setVisible(String id, bool value) { + final m = _sessions[id]; + if (m == null || m.visible == value) return; + m.visible = value; + notifyListeners(); + } + + /// Kill and forget a session (the real teardown). The conversation's + /// onDispose kills the process + closes its streams. + Future close(String id) async { + final m = _sessions.remove(id); + if (m == null) return; + m.conversation.dispose(); + notifyListeners(); + } + + @override + void dispose() { + for (final m in _sessions.values) { + m.conversation.dispose(); + } + _sessions.clear(); + super.dispose(); + } +} diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart new file mode 100644 index 00000000..169bceb4 --- /dev/null +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:clide/builtin/claude/src/session_orchestrator.dart'; +import 'package:clide/builtin/claude/src/stream_json_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeProc implements StreamJsonProcess { + final _ctl = StreamController.broadcast(); + bool killed = false; + @override + Stream get lines => _ctl.stream; + @override + void writeLine(String line) {} + @override + Future kill() async => killed = true; +} + +void main() { + late List<_FakeProc> created; + late ClaudeSessionOrchestrator orch; + + setUp(() { + created = []; + orch = ClaudeSessionOrchestrator(processFactory: ({required sessionArgs, required cwd, env}) async { + final p = _FakeProc(); + created.add(p); + return p; + }); + }); + + SpawnSpec spec(String id, {bool visible = true}) => SpawnSpec(id: id, role: id, sessionId: '$id-uuid', cwd: '/repo', visible: visible); + + test('spawns multiple concurrent sessions, each with its own process', () async { + await orch.spawn(spec('primary')); + await orch.spawn(spec('teammate:tyre')); + expect(orch.sessions, hasLength(2)); + expect(created, hasLength(2)); + expect(orch.byId('teammate:tyre')!.role, 'teammate:tyre'); + }); + + test('spawn is idempotent on id — no second process', () async { + final a = await orch.spawn(spec('primary')); + final b = await orch.spawn(spec('primary')); + expect(identical(a, b), isTrue); + expect(created, hasLength(1)); + }); + + test('hide keeps the process alive and in the registry; show restores it', () async { + await orch.spawn(spec('primary')); + orch.hide('primary'); + expect(orch.byId('primary')!.visible, isFalse); + expect(orch.visibleSessions, isEmpty); + expect(orch.sessions, hasLength(1)); // still registered + expect(created.single.killed, isFalse); // NOT torn down + + orch.show('primary'); + expect(orch.byId('primary')!.visible, isTrue); + expect(orch.visibleSessions, hasLength(1)); + }); + + test('close kills the process and removes the session', () async { + await orch.spawn(spec('primary')); + await orch.close('primary'); + expect(orch.byId('primary'), isNull); + expect(orch.sessions, isEmpty); + await Future.delayed(Duration.zero); // session.dispose is async + expect(created.single.killed, isTrue); + }); + + test('visibleSessions filters hidden ones', () async { + await orch.spawn(spec('a')); + await orch.spawn(spec('b', visible: false)); + expect(orch.visibleSessions.map((m) => m.id), ['a']); + }); + + test('notifies on spawn / hide / close', () async { + var n = 0; + orch.addListener(() => n++); + await orch.spawn(spec('primary')); + orch.hide('primary'); + await orch.close('primary'); + expect(n, 3); + }); + + test('dispose kills every session', () async { + await orch.spawn(spec('a')); + await orch.spawn(spec('b')); + orch.dispose(); + await Future.delayed(Duration.zero); + expect(created.every((p) => p.killed), isTrue); + }); +}