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,671 @@
1
+ """Application orchestration for manual agent switching and canonical handoff delivery.
2
+
3
+ `SwitchService` is the service behind CortexShift's central product promise: one Task,
4
+ multiple coding agents, no need to manually re-explain the work.
5
+
6
+ Critically, the outgoing agent is never required. The handoff is derived deterministically
7
+ from durable local state — canonical Project, canonical Task, previous CortexShift Session
8
+ metadata, live repository inspection, and the Git snapshot captured at switch time — so
9
+ switching still works after the outgoing provider's quota is exhausted, its process is
10
+ gone, or its CLI is no longer even installed. No outgoing model call is ever made.
11
+
12
+ The service renders no Rich output, knows nothing about Typer, contains no SQL, and
13
+ constructs no provider-specific commands; provider variance lives behind handoff
14
+ delivery adapters.
15
+ """
16
+
17
+ import shutil
18
+ import sys
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from pydantic import BaseModel, ConfigDict
25
+
26
+ from cortexshift.adapters.git.inspector import GitRepositoryInspector
27
+ from cortexshift.adapters.process_runner import SubprocessInteractiveProcessRunner
28
+ from cortexshift.adapters.providers.antigravity import (
29
+ ANTIGRAVITY_MCP_MISSING_NOTICE,
30
+ AntigravityHandoffAdapter,
31
+ is_antigravity_mcp_configured,
32
+ )
33
+ from cortexshift.adapters.providers.claude import ClaudeHandoffAdapter
34
+ from cortexshift.adapters.providers.codex import CodexHandoffAdapter
35
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
36
+ from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
37
+ from cortexshift.application.checkpoint_service import CheckpointService
38
+ from cortexshift.application.handoff_builder import HandoffBuilder, aggregate_task_decisions
39
+ from cortexshift.application.handoff_renderer import HandoffRenderer, RenderedHandoffContext
40
+ from cortexshift.application.locator import ProjectLocator
41
+ from cortexshift.application.native_session import native_capabilities, select_native_session
42
+ from cortexshift.application.run_service import ProviderRuntimeRegistry
43
+ from cortexshift.application.session_launcher import ProviderSessionLauncher
44
+ from cortexshift.application.source_session import select_source_session
45
+ from cortexshift.domain.errors import (
46
+ GitProbeError,
47
+ HandoffDeliveryError,
48
+ NativeResumeError,
49
+ NoActiveTaskError,
50
+ ProjectNotInitializedError,
51
+ ProviderNotFoundError,
52
+ SameProviderSwitchError,
53
+ TerminalRequiredError,
54
+ UnknownProviderError,
55
+ WorkspaceLockedError,
56
+ )
57
+ from cortexshift.domain.git import RepositoryInspection, RepositoryInspectionStatus
58
+ from cortexshift.domain.handoff import (
59
+ HANDOFF_PROTOCOL_VERSION,
60
+ HandoffFailureCode,
61
+ HandoffPayload,
62
+ HandoffRecord,
63
+ HandoffStatus,
64
+ )
65
+ from cortexshift.domain.identifiers import utc_now
66
+ from cortexshift.domain.launch import LaunchSpecification
67
+ from cortexshift.domain.project import Project
68
+ from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY
69
+ from cortexshift.domain.session import Session, SessionExitReason
70
+ from cortexshift.domain.task import Task
71
+ from cortexshift.ports.handoff_delivery import ProviderHandoffAdapter
72
+ from cortexshift.ports.process_runner import InteractiveProcessRunner
73
+ from cortexshift.ports.provider import ManagedMcpBinder
74
+ from cortexshift.ports.repository import RepositoryInspector
75
+ from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
76
+
77
+
78
+ class ProviderHandoffRegistry:
79
+ """Registry of supported provider handoff delivery adapters."""
80
+
81
+ def __init__(self, adapters: list[ProviderHandoffAdapter] | None = None) -> None:
82
+ self._adapters: dict[str, ProviderHandoffAdapter] = {}
83
+ if adapters is None:
84
+ adapters = [
85
+ ClaudeHandoffAdapter(),
86
+ CodexHandoffAdapter(),
87
+ AntigravityHandoffAdapter(),
88
+ ]
89
+ for adapter in adapters:
90
+ self._adapters[str(adapter.provider_id).lower()] = adapter
91
+
92
+ def get(self, provider_id_str: str) -> ProviderHandoffAdapter | None:
93
+ return self._adapters.get(provider_id_str.strip().lower())
94
+
95
+ def list_supported_ids(self) -> list[str]:
96
+ return sorted(self._adapters.keys())
97
+
98
+
99
+ class SwitchDryRunResult(BaseModel):
100
+ """Preview of what an actual switch would do, without side effects of any kind."""
101
+
102
+ model_config = ConfigDict(frozen=True)
103
+
104
+ protocol_version: int = HANDOFF_PROTOCOL_VERSION
105
+ project_id: str
106
+ project_name: str
107
+ task_id: str
108
+ task_title: str
109
+
110
+ source_session_id: str
111
+ source_provider_id: str
112
+ source_session_status: str
113
+
114
+ target_provider_id: str
115
+ target_provider_name: str
116
+ target_native_mode: str = "new_session"
117
+ selected_prior_target_session_id: str | None = None
118
+ native_session_known: bool = False
119
+ native_session_id: str | None = None
120
+ target_executable: str
121
+
122
+ git_status: str
123
+ git_branch: str | None = None
124
+ git_head_sha: str | None = None
125
+ git_dirty: bool = False
126
+
127
+ delivery_strategy: str
128
+ bootstrap_model_turn_required: bool
129
+
130
+ context_characters: int
131
+ context_max_characters: int
132
+ context_truncated: bool
133
+ context_omissions: list[dict[str, Any]] = []
134
+
135
+ def to_dict(self) -> dict[str, Any]:
136
+ """Serialize for machine-readable output."""
137
+ return self.model_dump(mode="json")
138
+
139
+
140
+ class HandoffPreviewResult(BaseModel):
141
+ """A rendered, unpersisted preview of the canonical handoff for a target provider."""
142
+
143
+ model_config = ConfigDict(frozen=True)
144
+
145
+ protocol_version: int = HANDOFF_PROTOCOL_VERSION
146
+ project_id: str
147
+ target_provider_id: str
148
+ target_provider_name: str
149
+ delivery_strategy: str
150
+ bootstrap_model_turn_required: bool
151
+ payload: HandoffPayload
152
+ rendered_context: str
153
+ context_characters: int
154
+ context_max_characters: int
155
+ context_truncated: bool
156
+ context_omissions: list[dict[str, Any]] = []
157
+
158
+ def to_dict(self) -> dict[str, Any]:
159
+ """Serialize for machine-readable output."""
160
+ return self.model_dump(mode="json")
161
+
162
+
163
+ class SwitchResult(BaseModel):
164
+ """Outcome of an executed switch.
165
+
166
+ Handoff delivery and target Session outcome are deliberately separate: context can be
167
+ delivered successfully and the receiving provider can still exit non-zero afterwards.
168
+ """
169
+
170
+ model_config = ConfigDict(frozen=True)
171
+
172
+ handoff: HandoffRecord
173
+ target_session: Session
174
+ bootstrap_performed: bool = False
175
+
176
+
177
+ @dataclass
178
+ class _SwitchContext:
179
+ """Resolved, validated inputs for a handoff operation."""
180
+
181
+ project_root: Path
182
+ project: Project
183
+ task: Task
184
+ source_session: Session
185
+ adapter: ProviderHandoffAdapter
186
+ executable_path: str
187
+ store: SQLiteStateStore
188
+ prior_target: Session | None = None
189
+
190
+
191
+ class SwitchService:
192
+ """Orchestrates canonical handoff generation and delivery to a receiving provider."""
193
+
194
+ def __init__(
195
+ self,
196
+ registry: ProviderHandoffRegistry | None = None,
197
+ native_registry: ProviderRuntimeRegistry | None = None,
198
+ inspector: RepositoryInspector | None = None,
199
+ process_runner: InteractiveProcessRunner | None = None,
200
+ lease_manager: WorkspaceLeaseManager | None = None,
201
+ builder: HandoffBuilder | None = None,
202
+ renderer: HandoffRenderer | None = None,
203
+ which_fn: Callable[[str], str | None] | None = None,
204
+ is_tty_fn: Callable[[], bool] | None = None,
205
+ checkpoint_service: CheckpointService | None = None,
206
+ ) -> None:
207
+ self._registry = registry or ProviderHandoffRegistry()
208
+ self._native_registry = native_registry or ProviderRuntimeRegistry()
209
+ self._inspector = inspector or GitRepositoryInspector()
210
+ self._runner = process_runner or SubprocessInteractiveProcessRunner()
211
+ self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
212
+ self._builder = builder or HandoffBuilder()
213
+ self._renderer = renderer or HandoffRenderer()
214
+ self._which = which_fn if which_fn is not None else (lambda cmd: shutil.which(cmd))
215
+ self._is_tty = is_tty_fn if is_tty_fn is not None else self._check_tty
216
+ self._checkpoint_service = checkpoint_service or CheckpointService(
217
+ inspector=self._inspector
218
+ )
219
+
220
+ @staticmethod
221
+ def _check_tty() -> bool:
222
+ return sys.stdin.isatty() and sys.stdout.isatty()
223
+
224
+ # --- Context resolution ---
225
+
226
+ def _resolve_context(
227
+ self,
228
+ target_provider_name: str,
229
+ from_session_id: str | None,
230
+ start_dir: Path | str | None,
231
+ require_executable: bool = True,
232
+ ) -> _SwitchContext:
233
+ """Resolve and validate project, active task, source session, and target provider.
234
+
235
+ The source provider's executable is deliberately never probed: a handoff must not
236
+ depend on the outgoing agent still being installed or usable.
237
+ """
238
+ start_path = Path(start_dir) if start_dir is not None else None
239
+ project_root = ProjectLocator.find_project_root(start_path)
240
+ if project_root is None:
241
+ raise ProjectNotInitializedError()
242
+
243
+ db_path = ProjectLocator.get_database_path(project_root)
244
+ store = SQLiteStateStore(db_path, auto_migrate=False)
245
+
246
+ try:
247
+ project = store.get_default_project()
248
+ if project is None:
249
+ raise ProjectNotInitializedError()
250
+
251
+ active_task_id = store.get_active_task_id(project.id)
252
+ if not active_task_id:
253
+ raise NoActiveTaskError()
254
+
255
+ task = store.get_task(active_task_id)
256
+ if task is None:
257
+ raise NoActiveTaskError()
258
+
259
+ adapter = self._registry.get(target_provider_name)
260
+ if adapter is None:
261
+ raise UnknownProviderError(
262
+ target_provider_name, self._registry.list_supported_ids()
263
+ )
264
+
265
+ source_session = select_source_session(store, task, from_session_id)
266
+
267
+ if str(source_session.provider_id) == str(adapter.provider_id):
268
+ raise SameProviderSwitchError(adapter.display_name)
269
+
270
+ executable_path = ""
271
+ if require_executable:
272
+ resolved = self._which(adapter.executable)
273
+ if not resolved:
274
+ raise ProviderNotFoundError(f"{adapter.display_name} was not found in PATH.")
275
+ executable_path = resolved
276
+
277
+ return _SwitchContext(
278
+ project_root=project_root,
279
+ project=project,
280
+ task=task,
281
+ source_session=source_session,
282
+ adapter=adapter,
283
+ executable_path=executable_path,
284
+ store=store,
285
+ )
286
+ except Exception:
287
+ store.close()
288
+ raise
289
+
290
+ def _select_target(
291
+ self, context: _SwitchContext, new_session: bool, resume_session_id: str | None
292
+ ) -> None:
293
+ if new_session and resume_session_id is not None:
294
+ raise NativeResumeError("--new-session and --resume-session are mutually exclusive.")
295
+ adapter = self._native_registry.get(str(context.adapter.provider_id))
296
+ if new_session:
297
+ context.prior_target = None
298
+ elif (capabilities := native_capabilities(adapter)).supports_exact_resume:
299
+ if capabilities.can_resume_with_followup_context:
300
+ context.prior_target = select_native_session(
301
+ context.store,
302
+ context.task.id,
303
+ context.adapter.provider_id,
304
+ capabilities,
305
+ resume_session_id,
306
+ )
307
+ elif resume_session_id is not None:
308
+ raise NativeResumeError("Provider cannot receive fresh context on exact resume.")
309
+ elif resume_session_id is not None:
310
+ raise NativeResumeError("Provider does not support exact resume.")
311
+
312
+ def _inspect(self, context: _SwitchContext) -> RepositoryInspection:
313
+ """Inspect the live repository for the project being handed off."""
314
+ return self._inspector.inspect(
315
+ project_root=context.project_root,
316
+ project_id=context.project.id,
317
+ )
318
+
319
+ def _aggregate_task_decisions(self, context: _SwitchContext) -> list[str]:
320
+ """Collect the decisions recorded across the bound task's checkpoint history.
321
+
322
+ Checkpoints stay immutable point-in-time observations, so a decision recorded by
323
+ `record_decision` is not copied forward into later checkpoints. Reading the whole
324
+ history here is what keeps such a decision in the handoff after an ordinary or
325
+ session-end checkpoint has since become the newest one. The query is scoped to
326
+ `context.task.id`, so decisions never cross task boundaries.
327
+ """
328
+ history = context.store.list_task_checkpoint_history(context.task.id)
329
+ return aggregate_task_decisions(history, context.task.id)
330
+
331
+ # --- Non-mutating operations ---
332
+
333
+ def preview(
334
+ self,
335
+ target_provider_name: str,
336
+ from_session_id: str | None = None,
337
+ note: str | None = None,
338
+ start_dir: Path | str | None = None,
339
+ ) -> HandoffPreviewResult:
340
+ """Build and render the canonical handoff without persisting or launching anything.
341
+
342
+ Persists no handoff, captures no Git snapshot, creates no target Session, acquires
343
+ no long-lived workspace lease, runs no provider bootstrap, and consumes zero model
344
+ quota.
345
+ """
346
+ context = self._resolve_context(
347
+ target_provider_name,
348
+ from_session_id,
349
+ start_dir,
350
+ require_executable=False,
351
+ )
352
+ try:
353
+ inspection = self._inspect(context)
354
+ latest_checkpoint = context.store.get_latest_checkpoint(context.task.id)
355
+ task_decisions = self._aggregate_task_decisions(context)
356
+ payload = self._builder.build(
357
+ project=context.project,
358
+ task=context.task,
359
+ source_session=context.source_session,
360
+ inspection=inspection,
361
+ target_provider_id=context.adapter.provider_id,
362
+ snapshot_id=None,
363
+ operator_note=note,
364
+ latest_checkpoint=latest_checkpoint,
365
+ task_decisions=task_decisions,
366
+ )
367
+ rendered = self._renderer.render(payload, handoff_id=None)
368
+
369
+ return HandoffPreviewResult(
370
+ project_id=context.project.id,
371
+ target_provider_id=str(context.adapter.provider_id),
372
+ target_provider_name=context.adapter.display_name,
373
+ delivery_strategy=context.adapter.delivery_strategy.value,
374
+ bootstrap_model_turn_required=context.adapter.bootstrap_model_turn_required,
375
+ payload=payload,
376
+ rendered_context=rendered.text,
377
+ context_characters=rendered.character_count,
378
+ context_max_characters=rendered.max_characters,
379
+ context_truncated=rendered.truncated,
380
+ context_omissions=[o.model_dump(mode="json") for o in rendered.omissions],
381
+ )
382
+ finally:
383
+ context.store.close()
384
+
385
+ def dry_run(
386
+ self,
387
+ target_provider_name: str,
388
+ from_session_id: str | None = None,
389
+ note: str | None = None,
390
+ start_dir: Path | str | None = None,
391
+ new_session: bool = False,
392
+ resume_session_id: str | None = None,
393
+ ) -> SwitchDryRunResult:
394
+ """Describe what an actual switch would do without persisting or launching anything.
395
+
396
+ Notably, a dry run of an Antigravity switch never performs the read-only plan
397
+ bootstrap, so it consumes no model quota.
398
+ """
399
+ context = self._resolve_context(target_provider_name, from_session_id, start_dir)
400
+ try:
401
+ self._select_target(context, new_session, resume_session_id)
402
+ inspection = self._inspect(context)
403
+ latest_checkpoint = context.store.get_latest_checkpoint(context.task.id)
404
+ task_decisions = self._aggregate_task_decisions(context)
405
+ payload = self._builder.build(
406
+ project=context.project,
407
+ task=context.task,
408
+ source_session=context.source_session,
409
+ inspection=inspection,
410
+ target_provider_id=context.adapter.provider_id,
411
+ snapshot_id=None,
412
+ operator_note=note,
413
+ latest_checkpoint=latest_checkpoint,
414
+ task_decisions=task_decisions,
415
+ )
416
+ rendered = self._renderer.render(payload, handoff_id=None)
417
+ snapshot = inspection.snapshot
418
+
419
+ return SwitchDryRunResult(
420
+ project_id=context.project.id,
421
+ project_name=context.project.name,
422
+ task_id=context.task.id,
423
+ task_title=context.task.title,
424
+ source_session_id=context.source_session.id,
425
+ source_provider_id=str(context.source_session.provider_id),
426
+ source_session_status=context.source_session.status.value,
427
+ target_provider_id=str(context.adapter.provider_id),
428
+ target_provider_name=context.adapter.display_name,
429
+ target_executable=context.executable_path,
430
+ target_native_mode="resume_existing" if context.prior_target else "new_session",
431
+ selected_prior_target_session_id=(
432
+ context.prior_target.id if context.prior_target else None
433
+ ),
434
+ native_session_known=context.prior_target is not None,
435
+ native_session_id=(
436
+ context.prior_target.native_session_id if context.prior_target else None
437
+ ),
438
+ git_status=inspection.status.value,
439
+ git_branch=snapshot.branch if snapshot else None,
440
+ git_head_sha=snapshot.head_sha if snapshot else None,
441
+ git_dirty=snapshot.dirty if snapshot else False,
442
+ delivery_strategy=context.adapter.delivery_strategy.value,
443
+ bootstrap_model_turn_required=context.adapter.bootstrap_model_turn_required,
444
+ context_characters=rendered.character_count,
445
+ context_max_characters=rendered.max_characters,
446
+ context_truncated=rendered.truncated,
447
+ context_omissions=[o.model_dump(mode="json") for o in rendered.omissions],
448
+ )
449
+ finally:
450
+ context.store.close()
451
+
452
+ # --- Executed switch ---
453
+
454
+ def switch(
455
+ self,
456
+ target_provider_name: str,
457
+ from_session_id: str | None = None,
458
+ note: str | None = None,
459
+ start_dir: Path | str | None = None,
460
+ new_session: bool = False,
461
+ resume_session_id: str | None = None,
462
+ on_prepared: Callable[[HandoffRecord, RenderedHandoffContext], None] | None = None,
463
+ on_launch: Callable[[LaunchSpecification, Session], None] | None = None,
464
+ ) -> SwitchResult:
465
+ """Hand the active task off to another provider and launch the receiving agent.
466
+
467
+ The workspace lease is held continuously from repository observation through
468
+ target provider runtime, so the Git state described by the handoff cannot drift
469
+ under another CortexShift agent before the receiving agent starts.
470
+
471
+ Task progress is never mutated: `switch` changes only handoff, session, and Git
472
+ snapshot orchestration state.
473
+ """
474
+ context = self._resolve_context(target_provider_name, from_session_id, start_dir)
475
+ store = context.store
476
+
477
+ try:
478
+ if not self._is_tty():
479
+ raise TerminalRequiredError(
480
+ "Handing off to an interactive provider requires a terminal (TTY).\n\n"
481
+ f"To preview the switch non-interactively, use: "
482
+ f"cortexshift switch {target_provider_name} --dry-run"
483
+ )
484
+
485
+ lease = self._lease_manager.get_lease(Path(context.project.repo_path))
486
+ if not lease.acquire():
487
+ raise WorkspaceLockedError(lock_path=lease.lock_path)
488
+
489
+ try:
490
+ self._select_target(context, new_session, resume_session_id)
491
+ inspection = self._inspect(context)
492
+ snapshot_id = self._persist_snapshot(inspection, store)
493
+
494
+ latest_checkpoint = store.get_latest_checkpoint(context.task.id)
495
+ task_decisions = self._aggregate_task_decisions(context)
496
+ payload = self._builder.build(
497
+ project=context.project,
498
+ task=context.task,
499
+ source_session=context.source_session,
500
+ inspection=inspection,
501
+ target_provider_id=context.adapter.provider_id,
502
+ snapshot_id=snapshot_id,
503
+ operator_note=note,
504
+ latest_checkpoint=latest_checkpoint,
505
+ task_decisions=task_decisions,
506
+ )
507
+
508
+ handoff = HandoffRecord(
509
+ protocol_version=HANDOFF_PROTOCOL_VERSION,
510
+ project_id=context.project.id,
511
+ task_id=context.task.id,
512
+ source_session_id=context.source_session.id,
513
+ source_provider_id=context.source_session.provider_id,
514
+ target_provider_id=context.adapter.provider_id,
515
+ source_checkpoint_id=latest_checkpoint.id if latest_checkpoint else None,
516
+ git_snapshot_id=snapshot_id,
517
+ status=HandoffStatus.PREPARED,
518
+ payload=payload,
519
+ metadata={
520
+ "delivery_strategy": context.adapter.delivery_strategy.value,
521
+ },
522
+ )
523
+ store.save_handoff(handoff)
524
+
525
+ rendered = self._renderer.render(payload, handoff_id=handoff.id)
526
+ if on_prepared:
527
+ on_prepared(handoff, rendered)
528
+
529
+ return self._deliver(context, handoff, rendered, on_launch)
530
+ finally:
531
+ lease.release()
532
+ finally:
533
+ store.close()
534
+
535
+ def _persist_snapshot(
536
+ self,
537
+ inspection: RepositoryInspection,
538
+ store: SQLiteStateStore,
539
+ ) -> str | None:
540
+ """Persist the Git snapshot when Git is ready, honestly handling other states.
541
+
542
+ A known `git_not_installed` or `not_git_repository` state continues the handoff
543
+ with an explicit canonical marker and no snapshot row. An unexpected probe error
544
+ fails the switch instead of producing a package whose repository observation may
545
+ be unreliable. Fake snapshot rows are never created.
546
+ """
547
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
548
+ raise GitProbeError(
549
+ inspection.diagnostic
550
+ or "Git repository inspection failed; refusing to build a handoff "
551
+ "from an unreliable repository observation."
552
+ )
553
+
554
+ if inspection.status != RepositoryInspectionStatus.READY or inspection.snapshot is None:
555
+ return None
556
+
557
+ store.save_snapshot(inspection.snapshot)
558
+ return inspection.snapshot.id
559
+
560
+ def _deliver(
561
+ self,
562
+ context: _SwitchContext,
563
+ handoff: HandoffRecord,
564
+ rendered: RenderedHandoffContext,
565
+ on_launch: Callable[[LaunchSpecification, Session], None] | None,
566
+ ) -> SwitchResult:
567
+ """Deliver the rendered context through the target provider's strategy."""
568
+ store = context.store
569
+ launcher = ProviderSessionLauncher(
570
+ process_runner=self._runner,
571
+ store=store,
572
+ checkpoint_service=self._checkpoint_service,
573
+ )
574
+
575
+ target_session = launcher.start_session(
576
+ task_id=context.task.id,
577
+ provider_id=context.adapter.provider_id,
578
+ metadata={"handoff_id": handoff.id},
579
+ resumed_from_session_id=context.prior_target.id if context.prior_target else None,
580
+ native_session_id=(
581
+ context.prior_target.native_session_id if context.prior_target else None
582
+ ),
583
+ )
584
+
585
+ try:
586
+ preparation = context.adapter.prepare_delivery(
587
+ executable_path=context.executable_path,
588
+ project_root=Path(context.project.repo_path),
589
+ rendered_context=rendered.text,
590
+ native_session_id=(
591
+ context.prior_target.native_session_id if context.prior_target else None
592
+ ),
593
+ )
594
+ except HandoffDeliveryError as err:
595
+ target_session = launcher.fail_session(target_session, SessionExitReason.SPAWN_FAILED)
596
+ self._fail_handoff(store, handoff, err.failure_code, target_session.id)
597
+ raise
598
+
599
+ except BaseException as err:
600
+ launcher.fail_session(target_session, SessionExitReason.SPAWN_FAILED)
601
+ self._fail_handoff(
602
+ store, handoff, HandoffFailureCode.BOOTSTRAP_FAILED.value, target_session.id
603
+ )
604
+ if isinstance(err, (KeyboardInterrupt, SystemExit)):
605
+ raise
606
+ raise HandoffDeliveryError(
607
+ "bootstrap_failed",
608
+ "Provider handoff preparation failed; no fallback was attempted.",
609
+ ) from None
610
+
611
+ if preparation.native_session_id:
612
+ target_session = launcher.attach_native_session_id(
613
+ target_session, preparation.native_session_id
614
+ )
615
+
616
+ delivered = handoff.mark_delivered(
617
+ target_session_id=target_session.id,
618
+ delivered_at=utc_now(),
619
+ )
620
+ store.update_handoff_delivery(
621
+ delivered.id,
622
+ status=HandoffStatus.DELIVERED,
623
+ target_session_id=target_session.id,
624
+ delivered_at=delivered.delivered_at,
625
+ )
626
+
627
+ if str(context.adapter.provider_id) == str(
628
+ PROVIDER_ANTIGRAVITY
629
+ ) and not is_antigravity_mcp_configured(Path(context.project.repo_path)):
630
+ sys.stderr.write(f"\n{ANTIGRAVITY_MCP_MISSING_NOTICE}\n\n")
631
+
632
+ try:
633
+ target_session = launcher.run(
634
+ target_session,
635
+ preparation.launch_spec,
636
+ on_launch=on_launch,
637
+ mcp_binder=(
638
+ context.adapter if isinstance(context.adapter, ManagedMcpBinder) else None
639
+ ),
640
+ )
641
+ except Exception:
642
+ # The interactive process never started, so context was not actually delivered.
643
+ self._fail_handoff(
644
+ store, delivered, HandoffFailureCode.SPAWN_FAILED.value, target_session.id
645
+ )
646
+ raise
647
+
648
+ return SwitchResult(
649
+ handoff=delivered,
650
+ target_session=target_session,
651
+ bootstrap_performed=preparation.bootstrap_performed,
652
+ )
653
+
654
+ @staticmethod
655
+ def _fail_handoff(
656
+ store: SQLiteStateStore,
657
+ handoff: HandoffRecord,
658
+ failure_code: str,
659
+ target_session_id: str | None,
660
+ ) -> None:
661
+ """Record a safe machine classification for a failed handoff delivery."""
662
+ try:
663
+ code = HandoffFailureCode(failure_code)
664
+ except ValueError:
665
+ code = HandoffFailureCode.BOOTSTRAP_FAILED
666
+ store.update_handoff_delivery(
667
+ handoff.id,
668
+ status=HandoffStatus.FAILED,
669
+ target_session_id=target_session_id,
670
+ failure_code=code,
671
+ )