cortexshift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,614 @@
1
+ """Read-model assembly and use-case coordination for the terminal control center.
2
+
3
+ `TuiFacade` is the only object the dashboard talks to. It aggregates the existing
4
+ application services, projects their results into immutable presentation models, and
5
+ holds no business rules of its own: task rules stay in `Task`/`TaskService`, recovery
6
+ stays in `RecoveryService`, handoff generation stays in `SwitchService`, provider argv
7
+ stays behind the provider adapters.
8
+
9
+ Architectural boundaries enforced here:
10
+
11
+ - No SQL, and no direct SQLite access — persistence is reached through application
12
+ services only.
13
+ - No Git subprocess invocation — repository truth comes from `RepositoryService`.
14
+ - Every call is bound to the project root resolved when the facade was constructed, so
15
+ no dashboard action can reach a different project.
16
+ - The dashboard holds no workspace lease. Operations that require exclusivity (recovery,
17
+ run, resume, switch) acquire it inside their own services, which reject unsafe attempts.
18
+ """
19
+
20
+ import shutil
21
+ import time
22
+ from collections.abc import Callable
23
+ from pathlib import Path
24
+
25
+ from cortexshift import __version__
26
+ from cortexshift.adapters.providers.antigravity import (
27
+ ANTIGRAVITY_MCP_CONFIG_REL_PATH,
28
+ is_antigravity_mcp_configured,
29
+ setup_antigravity_mcp,
30
+ )
31
+ from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
32
+ from cortexshift.application.checkpoint_service import CheckpointService
33
+ from cortexshift.application.doctor import DoctorService
34
+ from cortexshift.application.handoff_service import HandoffService
35
+ from cortexshift.application.locator import ProjectLocator
36
+ from cortexshift.application.native_session import is_native_resumable, native_capabilities
37
+ from cortexshift.application.recovery_service import RecoveryReport, RecoveryService
38
+ from cortexshift.application.repository_service import RepositoryService
39
+ from cortexshift.application.run_service import ProviderRuntimeRegistry
40
+ from cortexshift.application.session_service import SessionService
41
+ from cortexshift.application.status_service import ProjectStatusService
42
+ from cortexshift.application.switch_service import SwitchService
43
+ from cortexshift.application.task_workspace import TaskWorkspaceService
44
+ from cortexshift.domain.checkpoint import CheckpointKind, CheckpointRecord, CheckpointTestProvenance
45
+ from cortexshift.domain.errors import ProjectNotInitializedError
46
+ from cortexshift.domain.git import RepositoryInspectionStatus
47
+ from cortexshift.domain.handoff import HandoffRecord
48
+ from cortexshift.domain.identifiers import utc_now
49
+ from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX
50
+ from cortexshift.domain.session import Session
51
+ from cortexshift.domain.task import Task
52
+ from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
53
+ from cortexshift.tui.models import (
54
+ TuiActivityModel,
55
+ TuiCheckpointRow,
56
+ TuiHandoffPreview,
57
+ TuiHandoffRow,
58
+ TuiMcpStatus,
59
+ TuiProjectModel,
60
+ TuiProviderStatus,
61
+ TuiRecoveryPreview,
62
+ TuiRepositoryModel,
63
+ TuiSessionRow,
64
+ TuiStateSnapshot,
65
+ TuiSwitchPreview,
66
+ TuiTaskModel,
67
+ TuiTaskRow,
68
+ WorkspaceActivity,
69
+ )
70
+
71
+ DEFAULT_SESSION_LIMIT = 50
72
+ DEFAULT_CHECKPOINT_LIMIT = 50
73
+ DEFAULT_HANDOFF_LIMIT = 50
74
+ PROVIDER_CACHE_SECONDS = 30.0
75
+
76
+ MCP_READ_TOOLS = (
77
+ "get_project_context",
78
+ "get_current_task",
79
+ "get_latest_checkpoint",
80
+ "get_repository_status",
81
+ )
82
+ MCP_WRITE_TOOLS = (
83
+ "set_current_work",
84
+ "mark_completed",
85
+ "add_remaining",
86
+ "record_issue",
87
+ "record_decision",
88
+ "create_checkpoint",
89
+ )
90
+ MCP_RESOURCES = (
91
+ "cortexshift://project",
92
+ "cortexshift://task",
93
+ "cortexshift://checkpoint/latest",
94
+ "cortexshift://repository",
95
+ )
96
+
97
+ MCP_INTEGRATION_MODES = {
98
+ str(PROVIDER_CLAUDE): "automatic per launch (--mcp-config)",
99
+ str(PROVIDER_CODEX): "automatic per launch (-c overrides)",
100
+ str(PROVIDER_ANTIGRAVITY): "workspace config (.agents/mcp_config.json)",
101
+ }
102
+
103
+
104
+ class TuiFacade:
105
+ """Assembles dashboard read models and coordinates existing use cases."""
106
+
107
+ def __init__(
108
+ self,
109
+ project_root: Path,
110
+ *,
111
+ status_service: ProjectStatusService | None = None,
112
+ task_workspace: TaskWorkspaceService | None = None,
113
+ session_service: SessionService | None = None,
114
+ checkpoint_service: CheckpointService | None = None,
115
+ handoff_service: HandoffService | None = None,
116
+ repository_service: RepositoryService | None = None,
117
+ recovery_service: RecoveryService | None = None,
118
+ doctor_service: DoctorService | None = None,
119
+ switch_service: SwitchService | None = None,
120
+ runtime_registry: ProviderRuntimeRegistry | None = None,
121
+ lease_manager: WorkspaceLeaseManager | None = None,
122
+ which_fn: Callable[[str], str | None] | None = None,
123
+ clock: Callable[[], float] = time.monotonic,
124
+ provider_cache_seconds: float = PROVIDER_CACHE_SECONDS,
125
+ ) -> None:
126
+ self._root = Path(project_root).resolve()
127
+ self._status = status_service or ProjectStatusService()
128
+ self._task_workspace = task_workspace or TaskWorkspaceService()
129
+ self._sessions = session_service or SessionService()
130
+ self._checkpoints = checkpoint_service or CheckpointService()
131
+ self._handoffs = handoff_service or HandoffService()
132
+ self._repository = repository_service or RepositoryService()
133
+ self._recovery = recovery_service or RecoveryService()
134
+ self._doctor = doctor_service or DoctorService()
135
+ self._switch = switch_service or SwitchService()
136
+ self._runtime_registry = runtime_registry or ProviderRuntimeRegistry()
137
+ self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
138
+ self._which = which_fn if which_fn is not None else (lambda cmd: shutil.which(cmd))
139
+ self._clock = clock
140
+ self._provider_cache_seconds = provider_cache_seconds
141
+ self._provider_cache: tuple[float, tuple[TuiProviderStatus, ...]] | None = None
142
+
143
+ @property
144
+ def project_root(self) -> Path:
145
+ """The single project root every dashboard action is bound to."""
146
+ return self._root
147
+
148
+ @classmethod
149
+ def resolve(
150
+ cls,
151
+ start_dir: Path | str | None = None,
152
+ **kwargs: object,
153
+ ) -> "TuiFacade":
154
+ """Build a facade bound to the nearest initialized project root.
155
+
156
+ Raises:
157
+ ProjectNotInitializedError: If no initialized project root is found.
158
+ """
159
+ start_path = Path(start_dir) if start_dir is not None else None
160
+ project_root = ProjectLocator.find_project_root(start_path)
161
+ if project_root is None:
162
+ raise ProjectNotInitializedError()
163
+ return cls(project_root, **kwargs) # type: ignore[arg-type]
164
+
165
+ # ------------------------------------------------------------------
166
+ # Lightweight state (SQLite-backed only; never spawns a subprocess)
167
+ # ------------------------------------------------------------------
168
+
169
+ def load_state(self) -> TuiStateSnapshot:
170
+ """Assemble every persisted read model in one pass.
171
+
172
+ Deliberately performs no Git inspection and no provider probing so the dashboard's
173
+ lightweight refresh timer stays cheap enough to run continuously.
174
+ """
175
+ status = self._status.get_status(self._root)
176
+ project = TuiProjectModel(
177
+ project_id=status.project_id,
178
+ name=status.name,
179
+ root=self._root,
180
+ state_file=status.state_file,
181
+ schema_version=status.schema_version,
182
+ cortexshift_version=__version__,
183
+ )
184
+
185
+ tasks = self._task_workspace.list_tasks(self._root)
186
+ active_task_id = status.active_task.id if status.active_task else None
187
+ active_task = next((t for t in tasks if t.id == active_task_id), None)
188
+
189
+ sessions = tuple(
190
+ self._session_row(session)
191
+ for session in self._sessions.list_sessions(self._root, limit=DEFAULT_SESSION_LIMIT)
192
+ )
193
+ checkpoints = tuple(
194
+ self._checkpoint_row(record)
195
+ for record in self._checkpoints.list_checkpoints(
196
+ task_id=active_task_id,
197
+ limit=DEFAULT_CHECKPOINT_LIMIT,
198
+ start_dir=self._root,
199
+ )
200
+ )
201
+ handoffs = tuple(
202
+ self._handoff_row(record)
203
+ for record in self._handoffs.list_handoffs(self._root, limit=DEFAULT_HANDOFF_LIMIT)
204
+ )
205
+
206
+ return TuiStateSnapshot(
207
+ project=project,
208
+ active_task=TuiTaskModel.from_task(active_task) if active_task else None,
209
+ tasks=tuple(self._task_row(task, active_task_id) for task in tasks),
210
+ sessions=sessions,
211
+ checkpoints=checkpoints,
212
+ handoffs=handoffs,
213
+ activity=TuiActivityModel(
214
+ latest_session=sessions[0] if sessions else None,
215
+ latest_checkpoint=checkpoints[0] if checkpoints else None,
216
+ latest_handoff=handoffs[0] if handoffs else None,
217
+ unfinalized_session_count=sum(1 for row in sessions if row.unfinalized),
218
+ ),
219
+ loaded_at=utc_now(),
220
+ )
221
+
222
+ # ------------------------------------------------------------------
223
+ # Live repository inspection (Worker-only; runs native Git)
224
+ # ------------------------------------------------------------------
225
+
226
+ def inspect_repository(self) -> TuiRepositoryModel:
227
+ """Run a live, strictly read-only Git inspection of the bound project."""
228
+ inspection = self._repository.inspect_repository(self._root)
229
+ snapshot = inspection.snapshot
230
+ if snapshot is None:
231
+ return TuiRepositoryModel(
232
+ status=inspection.status,
233
+ project_root=self._root,
234
+ git_available=inspection.git_available,
235
+ git_version=inspection.git_version,
236
+ diagnostic=inspection.diagnostic,
237
+ observed_at=utc_now(),
238
+ )
239
+
240
+ return TuiRepositoryModel(
241
+ status=inspection.status,
242
+ project_root=self._root,
243
+ git_available=inspection.git_available,
244
+ git_version=inspection.git_version or snapshot.git_version,
245
+ git_root=snapshot.git_root,
246
+ branch=snapshot.branch,
247
+ head_sha=snapshot.head_sha,
248
+ detached_head=snapshot.detached_head,
249
+ dirty=snapshot.dirty,
250
+ staged_files=tuple(snapshot.staged_files),
251
+ modified_files=tuple(snapshot.modified_files),
252
+ untracked_files=tuple(snapshot.untracked_files),
253
+ conflicted_files=tuple(snapshot.conflicted_files),
254
+ working_tree_diff_summary=snapshot.working_tree_diff_summary,
255
+ staged_diff_summary=snapshot.staged_diff_summary,
256
+ diagnostic=inspection.diagnostic,
257
+ observed_at=snapshot.captured_at,
258
+ )
259
+
260
+ # ------------------------------------------------------------------
261
+ # Provider discovery and MCP status (Worker-only; probes native CLIs)
262
+ # ------------------------------------------------------------------
263
+
264
+ def provider_status(self, *, refresh: bool = False) -> tuple[TuiProviderStatus, ...]:
265
+ """Passively discover provider CLIs, with a short in-process cache.
266
+
267
+ The cache lives only for the lifetime of this dashboard process; nothing is
268
+ persisted. `refresh=True` forces a fresh probe.
269
+ """
270
+ now = self._clock()
271
+ if not refresh and self._provider_cache is not None:
272
+ cached_at, cached = self._provider_cache
273
+ if now - cached_at < self._provider_cache_seconds:
274
+ return cached
275
+
276
+ report = self._doctor.run_diagnostics()
277
+ statuses = tuple(
278
+ TuiProviderStatus(
279
+ provider_id=str(diagnostic.provider_id),
280
+ display_name=diagnostic.display_name,
281
+ executable=diagnostic.executable,
282
+ installed=diagnostic.installed,
283
+ version=diagnostic.version,
284
+ authentication=diagnostic.authentication_status.value,
285
+ supports_native_resume=diagnostic.capabilities.supports_native_resume,
286
+ supports_exact_resume=self._supports_exact_resume(str(diagnostic.provider_id)),
287
+ mcp_integration=MCP_INTEGRATION_MODES.get(
288
+ str(diagnostic.provider_id), "not integrated"
289
+ ),
290
+ diagnostics=tuple(diagnostic.diagnostics),
291
+ )
292
+ for diagnostic in report.providers
293
+ )
294
+ self._provider_cache = (now, statuses)
295
+ return statuses
296
+
297
+ def mcp_status(self) -> TuiMcpStatus:
298
+ """Summarize MCP availability and provider integration without starting a server."""
299
+ try:
300
+ import mcp
301
+
302
+ sdk_version = str(getattr(mcp, "__version__", "unknown"))
303
+ sdk_available = True
304
+ except Exception: # pragma: no cover - the SDK is a hard runtime dependency
305
+ sdk_version = "unavailable"
306
+ sdk_available = False
307
+
308
+ return TuiMcpStatus(
309
+ sdk_available=sdk_available,
310
+ sdk_version=sdk_version,
311
+ antigravity_configured=is_antigravity_mcp_configured(self._root),
312
+ antigravity_config_path=str(self._root / ANTIGRAVITY_MCP_CONFIG_REL_PATH),
313
+ read_tools=MCP_READ_TOOLS,
314
+ write_tools=MCP_WRITE_TOOLS,
315
+ resources=MCP_RESOURCES,
316
+ )
317
+
318
+ def configure_antigravity_mcp(
319
+ self, *, dry_run: bool = False, force: bool = False
320
+ ) -> dict[str, object]:
321
+ """Configure workspace MCP for Antigravity using the same safe setup path as the CLI.
322
+
323
+ Preserves unrelated servers and refuses to overwrite a conflicting entry unless
324
+ the operator explicitly forces it.
325
+ """
326
+ return setup_antigravity_mcp(self._root, dry_run=dry_run, force=force)
327
+
328
+ # ------------------------------------------------------------------
329
+ # Workspace activity (non-invasive advisory lock probe)
330
+ # ------------------------------------------------------------------
331
+
332
+ def workspace_activity(self) -> WorkspaceActivity:
333
+ """Probe whether another agent currently owns the exclusive workspace lease.
334
+
335
+ The OS advisory lock is authoritative: presence of the lock file on disk proves
336
+ nothing. When the lease is free the probe acquires and immediately releases it,
337
+ so the dashboard never holds the lease.
338
+ """
339
+ try:
340
+ lease = self._lease_manager.get_lease(self._root)
341
+ if lease.acquire():
342
+ lease.release()
343
+ return WorkspaceActivity.FREE
344
+ return WorkspaceActivity.BUSY
345
+ except Exception:
346
+ return WorkspaceActivity.UNKNOWN
347
+
348
+ # ------------------------------------------------------------------
349
+ # Task operations (delegated to canonical services)
350
+ # ------------------------------------------------------------------
351
+
352
+ def set_current_work(self, current_work: str | None) -> Task:
353
+ """Set the active Task's in-flight work description."""
354
+ return self._task_workspace.set_current_work(current_work, self._root)
355
+
356
+ def mark_completed(self, items: list[str]) -> Task:
357
+ """Mark items completed on the active Task and drop them from remaining."""
358
+ return self._task_workspace.mark_completed(items, self._root)
359
+
360
+ def add_remaining(self, items: list[str]) -> Task:
361
+ """Append newly discovered remaining items to the active Task."""
362
+ return self._task_workspace.add_remaining(items, self._root)
363
+
364
+ def record_issues(self, items: list[str]) -> Task:
365
+ """Record known issues on the active Task."""
366
+ return self._task_workspace.record_issues(items, self._root)
367
+
368
+ def activate_task(self, task_id: str) -> Task:
369
+ """Activate an eligible Task using canonical activation rules."""
370
+ return self._task_workspace.activate_task(task_id, self._root)
371
+
372
+ def start_task(
373
+ self,
374
+ title: str,
375
+ objective: str,
376
+ requirements: list[str] | None = None,
377
+ constraints: list[str] | None = None,
378
+ ) -> Task:
379
+ """Create a new Task and make it active."""
380
+ return self._task_workspace.start_task(
381
+ title=title,
382
+ objective=objective,
383
+ requirements=requirements,
384
+ constraints=constraints,
385
+ start_dir=self._root,
386
+ )
387
+
388
+ # ------------------------------------------------------------------
389
+ # Checkpoints, recovery, handoff preview
390
+ # ------------------------------------------------------------------
391
+
392
+ def create_checkpoint(
393
+ self,
394
+ decisions: list[str] | None = None,
395
+ test_summary: str | None = None,
396
+ note: str | None = None,
397
+ ) -> CheckpointRecord:
398
+ """Capture a cooperative MANUAL checkpoint without acquiring the workspace lease.
399
+
400
+ Reported test summaries are recorded with `reported` provenance; CortexShift never
401
+ upgrades an operator's claim into verified evidence.
402
+ """
403
+ provenance = (
404
+ CheckpointTestProvenance.REPORTED if test_summary else CheckpointTestProvenance.UNKNOWN
405
+ )
406
+ return self._checkpoints.create_checkpoint(
407
+ kind=CheckpointKind.MANUAL,
408
+ decisions=decisions,
409
+ test_summary=test_summary,
410
+ test_provenance=provenance,
411
+ note=note,
412
+ start_dir=self._root,
413
+ )
414
+
415
+ def get_checkpoint(self, checkpoint_id: str) -> CheckpointRecord:
416
+ """Retrieve one immutable checkpoint by its full canonical identifier."""
417
+ return self._checkpoints.get_checkpoint(checkpoint_id, start_dir=self._root)
418
+
419
+ def preview_recovery(self) -> TuiRecoveryPreview:
420
+ """Preview what a recovery run would reconcile, mutating nothing."""
421
+ report = self._recovery.recover(start_dir=self._root, dry_run=True)
422
+ return self._recovery_preview(report)
423
+
424
+ def recover(self) -> RecoveryReport:
425
+ """Reconcile unfinalized sessions through the existing recovery service.
426
+
427
+ Recovery requires the exclusive workspace lease; if another agent owns the
428
+ workspace the service raises and the dashboard reports it without mutating state.
429
+ """
430
+ return self._recovery.recover(start_dir=self._root, dry_run=False)
431
+
432
+ def preview_handoff(self, target_provider: str, note: str | None = None) -> TuiHandoffPreview:
433
+ """Render the canonical handoff for a target provider without side effects.
434
+
435
+ Persists no handoff, captures no snapshot, starts no bootstrap turn, and consumes
436
+ zero model quota.
437
+ """
438
+ result = self._switch.preview(
439
+ target_provider_name=target_provider,
440
+ note=note,
441
+ start_dir=self._root,
442
+ )
443
+ return TuiHandoffPreview(
444
+ target_provider_id=result.target_provider_id,
445
+ target_provider_name=result.target_provider_name,
446
+ delivery_strategy=result.delivery_strategy,
447
+ bootstrap_model_turn_required=result.bootstrap_model_turn_required,
448
+ rendered_context=result.rendered_context,
449
+ context_characters=result.context_characters,
450
+ context_max_characters=result.context_max_characters,
451
+ context_truncated=result.context_truncated,
452
+ )
453
+
454
+ def preview_switch(
455
+ self,
456
+ target_provider: str,
457
+ *,
458
+ new_session: bool = False,
459
+ resume_session_id: str | None = None,
460
+ note: str | None = None,
461
+ ) -> TuiSwitchPreview:
462
+ """Describe what a switch would do, using the existing switch dry-run path."""
463
+ result = self._switch.dry_run(
464
+ target_provider_name=target_provider,
465
+ note=note,
466
+ start_dir=self._root,
467
+ new_session=new_session,
468
+ resume_session_id=resume_session_id,
469
+ )
470
+
471
+ checkpoint_enrichment = "No checkpoint recorded for the active task."
472
+ latest = self._checkpoints.get_latest_checkpoint(start_dir=self._root)
473
+ if latest is not None:
474
+ decisions = len(latest.payload.decisions)
475
+ tests = "reported (unverified)" if latest.payload.test_status.known else "unknown"
476
+ checkpoint_enrichment = (
477
+ f"{latest.kind.value} checkpoint {latest.id} · "
478
+ f"{decisions} decision(s) · tests {tests}"
479
+ )
480
+
481
+ return TuiSwitchPreview(
482
+ target_provider_id=result.target_provider_id,
483
+ target_provider_name=result.target_provider_name,
484
+ source_provider_id=result.source_provider_id,
485
+ source_session_id=result.source_session_id,
486
+ task_id=result.task_id,
487
+ task_title=result.task_title,
488
+ target_native_mode=result.target_native_mode,
489
+ selected_prior_target_session_id=result.selected_prior_target_session_id,
490
+ git_status=result.git_status,
491
+ git_branch=result.git_branch,
492
+ git_dirty=result.git_dirty,
493
+ delivery_strategy=result.delivery_strategy,
494
+ bootstrap_model_turn_required=result.bootstrap_model_turn_required,
495
+ checkpoint_enrichment=checkpoint_enrichment,
496
+ context_characters=result.context_characters,
497
+ context_max_characters=result.context_max_characters,
498
+ context_truncated=result.context_truncated,
499
+ )
500
+
501
+ # ------------------------------------------------------------------
502
+ # Provider action eligibility
503
+ # ------------------------------------------------------------------
504
+
505
+ def supported_providers(self) -> tuple[str, ...]:
506
+ """Canonical provider identifiers the runtime registry can launch."""
507
+ return tuple(self._runtime_registry.list_supported_ids())
508
+
509
+ def resumable_sessions(self, provider_id: str) -> tuple[TuiSessionRow, ...]:
510
+ """Sessions that can be exactly resumed for a provider on the active task.
511
+
512
+ Selection reuses the canonical `is_native_resumable` rule; the dashboard never
513
+ guesses provider-native identity.
514
+ """
515
+ snapshot_sessions = self._sessions.list_sessions(self._root, limit=DEFAULT_SESSION_LIMIT)
516
+ active = self._task_workspace.get_active_task(self._root)
517
+ if active is None:
518
+ return ()
519
+ adapter = self._runtime_registry.get(provider_id)
520
+ capabilities = native_capabilities(adapter)
521
+ return tuple(
522
+ self._session_row(session)
523
+ for session in snapshot_sessions
524
+ if session.task_id == active.id
525
+ and str(session.provider_id) == provider_id
526
+ and is_native_resumable(session, capabilities)
527
+ )
528
+
529
+ def provider_available(self, provider_id: str) -> bool:
530
+ """Whether a provider executable currently resolves on PATH."""
531
+ adapter = self._runtime_registry.get(provider_id)
532
+ if adapter is None:
533
+ return False
534
+ return self._which(adapter.executable) is not None
535
+
536
+ # ------------------------------------------------------------------
537
+ # Projection helpers
538
+ # ------------------------------------------------------------------
539
+
540
+ def _supports_exact_resume(self, provider_id: str) -> bool:
541
+ adapter = self._runtime_registry.get(provider_id)
542
+ return native_capabilities(adapter).supports_exact_resume
543
+
544
+ def _session_row(self, session: Session) -> TuiSessionRow:
545
+ adapter = self._runtime_registry.get(str(session.provider_id))
546
+ resumable = is_native_resumable(session, native_capabilities(adapter))
547
+ return TuiSessionRow(
548
+ id=session.id,
549
+ task_id=session.task_id,
550
+ provider_id=str(session.provider_id),
551
+ status=session.status.value,
552
+ native_resumable=resumable,
553
+ native_session_id=session.native_session_id,
554
+ resumed_from_session_id=session.resumed_from_session_id,
555
+ exit_reason=session.exit_reason.value if session.exit_reason else None,
556
+ exit_code=session.exit_code,
557
+ started_at=session.started_at,
558
+ ended_at=session.ended_at,
559
+ reconciled_at=session.reconciled_at,
560
+ )
561
+
562
+ @staticmethod
563
+ def _task_row(task: Task, active_task_id: str | None) -> TuiTaskRow:
564
+ return TuiTaskRow(
565
+ id=task.id,
566
+ title=task.title,
567
+ status=task.status.value,
568
+ is_active=task.id == active_task_id,
569
+ is_terminal=task.is_terminal,
570
+ completed_count=len(task.completed),
571
+ remaining_count=len(task.remaining),
572
+ updated_at=task.updated_at,
573
+ )
574
+
575
+ @staticmethod
576
+ def _checkpoint_row(record: CheckpointRecord) -> TuiCheckpointRow:
577
+ git = record.payload.git_state
578
+ if git.status == RepositoryInspectionStatus.READY:
579
+ branch = git.branch or "(detached)"
580
+ summary = f"{branch} · {'dirty' if git.dirty else 'clean'}"
581
+ else:
582
+ summary = git.status.value
583
+ return TuiCheckpointRow(
584
+ id=record.id,
585
+ kind=record.kind.value,
586
+ session_id=record.session_id,
587
+ created_at=record.created_at,
588
+ git_summary=summary,
589
+ test_reported=record.payload.test_status.known,
590
+ record=record,
591
+ )
592
+
593
+ @staticmethod
594
+ def _handoff_row(record: HandoffRecord) -> TuiHandoffRow:
595
+ return TuiHandoffRow(
596
+ id=record.id,
597
+ source_provider_id=str(record.source_provider_id),
598
+ target_provider_id=str(record.target_provider_id),
599
+ status=record.status.value,
600
+ checkpoint_id=record.source_checkpoint_id,
601
+ created_at=record.created_at,
602
+ record=record,
603
+ )
604
+
605
+ @staticmethod
606
+ def _recovery_preview(report: RecoveryReport) -> TuiRecoveryPreview:
607
+ return TuiRecoveryPreview(
608
+ task_id=report.task_id,
609
+ task_title=report.task_title,
610
+ stale_session_ids=tuple(report.stale_session_ids),
611
+ dirty=report.dirty,
612
+ files_touched=tuple(report.files_touched),
613
+ repository_status=report.repository_status.value,
614
+ )