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,270 @@
1
+ """Application service for orchestrating native provider launch and session lifecycle."""
2
+
3
+ import shutil
4
+ import sys
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from cortexshift.adapters.process_runner import SubprocessInteractiveProcessRunner
12
+ from cortexshift.adapters.providers.antigravity import (
13
+ ANTIGRAVITY_MCP_MISSING_NOTICE,
14
+ AntigravityRuntimeAdapter,
15
+ is_antigravity_mcp_configured,
16
+ )
17
+ from cortexshift.adapters.providers.claude import ClaudeRuntimeAdapter
18
+ from cortexshift.adapters.providers.codex import CodexRuntimeAdapter
19
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
20
+ from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
21
+ from cortexshift.application.checkpoint_service import CheckpointService
22
+ from cortexshift.application.locator import ProjectLocator
23
+ from cortexshift.application.session_launcher import ProviderSessionLauncher
24
+ from cortexshift.domain.errors import (
25
+ NoActiveTaskError,
26
+ ProjectNotInitializedError,
27
+ ProviderNotFoundError,
28
+ TerminalRequiredError,
29
+ UnknownProviderError,
30
+ WorkspaceLockedError,
31
+ )
32
+ from cortexshift.domain.launch import LaunchSpecification
33
+ from cortexshift.domain.project import Project
34
+ from cortexshift.domain.provider import (
35
+ PROVIDER_ANTIGRAVITY,
36
+ ProviderId,
37
+ )
38
+ from cortexshift.domain.session import Session
39
+ from cortexshift.domain.task import Task
40
+ from cortexshift.ports.process_runner import InteractiveProcessRunner
41
+ from cortexshift.ports.provider import ManagedMcpBinder, ProviderRuntimeAdapter
42
+ from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
43
+
44
+
45
+ class DryRunResult(BaseModel):
46
+ """Result of a dry-run provider launch preview."""
47
+
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ provider_id: ProviderId
51
+ display_name: str
52
+ executable: str
53
+ project_id: str
54
+ project_name: str
55
+ task_id: str
56
+ task_title: str
57
+ cwd: Path
58
+ argv: list[str]
59
+ mode: str = "interactive"
60
+ prompt_supplied: bool = False
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ """Serialize dry-run result without leaking raw prompts."""
64
+ return {
65
+ "provider_id": str(self.provider_id),
66
+ "display_name": self.display_name,
67
+ "executable": self.executable,
68
+ "project_id": self.project_id,
69
+ "project_name": self.project_name,
70
+ "task_id": self.task_id,
71
+ "task_title": self.task_title,
72
+ "cwd": str(self.cwd),
73
+ "argv": self.argv,
74
+ "mode": self.mode,
75
+ "prompt_supplied": self.prompt_supplied,
76
+ }
77
+
78
+
79
+ class ProviderRuntimeRegistry:
80
+ """Registry of supported provider runtime adapters."""
81
+
82
+ def __init__(self, adapters: list[ProviderRuntimeAdapter] | None = None) -> None:
83
+ self._adapters: dict[str, ProviderRuntimeAdapter] = {}
84
+ if adapters is None:
85
+ adapters = [
86
+ ClaudeRuntimeAdapter(),
87
+ CodexRuntimeAdapter(),
88
+ AntigravityRuntimeAdapter(),
89
+ ]
90
+ for adapter in adapters:
91
+ self._adapters[str(adapter.provider_id).lower()] = adapter
92
+
93
+ def get(self, provider_id_str: str) -> ProviderRuntimeAdapter | None:
94
+ return self._adapters.get(provider_id_str.strip().lower())
95
+
96
+ def list_supported_ids(self) -> list[str]:
97
+ return sorted(self._adapters.keys())
98
+
99
+
100
+ class RunService:
101
+ """Orchestrates native provider process launching and session lifecycle."""
102
+
103
+ def __init__(
104
+ self,
105
+ registry: ProviderRuntimeRegistry | None = None,
106
+ process_runner: InteractiveProcessRunner | None = None,
107
+ lease_manager: WorkspaceLeaseManager | None = None,
108
+ which_fn: Callable[[str], str | None] | None = None,
109
+ is_tty_fn: Callable[[], bool] | None = None,
110
+ checkpoint_service: CheckpointService | None = None,
111
+ ) -> None:
112
+ self._registry = registry or ProviderRuntimeRegistry()
113
+ self._runner = process_runner or SubprocessInteractiveProcessRunner()
114
+ self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
115
+ self._which = which_fn if which_fn is not None else (lambda cmd: shutil.which(cmd))
116
+ self._is_tty = is_tty_fn if is_tty_fn is not None else self._check_tty
117
+ self._checkpoint_service = checkpoint_service or CheckpointService()
118
+
119
+ @staticmethod
120
+ def _check_tty() -> bool:
121
+ return sys.stdin.isatty() and sys.stdout.isatty()
122
+
123
+ def _resolve_context(
124
+ self,
125
+ provider_name: str,
126
+ start_dir: Path | str | None = None,
127
+ ) -> tuple[Path, Project, Task, ProviderRuntimeAdapter, str, SQLiteStateStore]:
128
+ """Resolve and validate project, active task, provider, and executable."""
129
+ start_path = Path(start_dir) if start_dir is not None else None
130
+ project_root = ProjectLocator.find_project_root(start_path)
131
+ if project_root is None:
132
+ raise ProjectNotInitializedError()
133
+
134
+ db_path = ProjectLocator.get_database_path(project_root)
135
+ store = SQLiteStateStore(db_path, auto_migrate=False)
136
+
137
+ try:
138
+ project = store.get_default_project()
139
+ if project is None:
140
+ raise ProjectNotInitializedError()
141
+
142
+ active_task_id = store.get_active_task_id(project.id)
143
+ if not active_task_id:
144
+ raise NoActiveTaskError()
145
+
146
+ task = store.get_task(active_task_id)
147
+ if task is None:
148
+ raise NoActiveTaskError()
149
+
150
+ adapter = self._registry.get(provider_name)
151
+ if adapter is None:
152
+ raise UnknownProviderError(provider_name, self._registry.list_supported_ids())
153
+
154
+ resolved_executable = self._which(adapter.executable)
155
+ if not resolved_executable:
156
+ raise ProviderNotFoundError(f"{adapter.display_name} was not found in PATH.")
157
+
158
+ return project_root, project, task, adapter, resolved_executable, store
159
+ except Exception:
160
+ store.close()
161
+ raise
162
+
163
+ def dry_run(
164
+ self,
165
+ provider_name: str,
166
+ prompt: str | None = None,
167
+ start_dir: Path | str | None = None,
168
+ ) -> DryRunResult:
169
+ """Perform a dry-run preview of provider launch without side effects."""
170
+ project_root, project, task, adapter, resolved_executable, store = self._resolve_context(
171
+ provider_name, start_dir
172
+ )
173
+ try:
174
+ launch_spec = adapter.build_launch_spec(
175
+ project_root=Path(project.repo_path),
176
+ executable_path=resolved_executable,
177
+ prompt=prompt,
178
+ )
179
+
180
+ return DryRunResult(
181
+ provider_id=adapter.provider_id,
182
+ display_name=adapter.display_name,
183
+ executable=resolved_executable,
184
+ project_id=project.id,
185
+ project_name=project.name,
186
+ task_id=task.id,
187
+ task_title=task.title,
188
+ cwd=launch_spec.cwd,
189
+ argv=launch_spec.to_redacted_argv(),
190
+ mode="interactive",
191
+ prompt_supplied=launch_spec.prompt_supplied,
192
+ )
193
+ finally:
194
+ store.close()
195
+
196
+ def run(
197
+ self,
198
+ provider_name: str,
199
+ prompt: str | None = None,
200
+ start_dir: Path | str | None = None,
201
+ on_launch: Callable[[LaunchSpecification, Session, Task, Project], None] | None = None,
202
+ ) -> Session:
203
+ """Launch an interactive provider session and manage its lifecycle.
204
+
205
+ Args:
206
+ provider_name: Canonical identifier of provider (e.g. 'claude', 'codex', 'antigravity').
207
+ prompt: Optional initial prompt.
208
+ start_dir: Directory where the command was invoked.
209
+ on_launch: Optional callback executed immediately before spawning process.
210
+
211
+ Returns:
212
+ The finalized Session entity.
213
+ """
214
+ project_root, project, task, adapter, resolved_executable, store = self._resolve_context(
215
+ provider_name, start_dir
216
+ )
217
+
218
+ try:
219
+ # 1. Check TTY requirement
220
+ if not self._is_tty():
221
+ raise TerminalRequiredError(
222
+ "Interactive provider launch requires a terminal (TTY).\n\n"
223
+ f"To simulate provider launch non-interactively, use: "
224
+ f"cortexshift run {provider_name} --dry-run"
225
+ )
226
+
227
+ # 2. Build launch specification (validates prompt capability before lease)
228
+ launch_spec = adapter.build_launch_spec(
229
+ project_root=Path(project.repo_path),
230
+ executable_path=resolved_executable,
231
+ prompt=prompt,
232
+ )
233
+
234
+ # 3. Acquire workspace lease
235
+ lease = self._lease_manager.get_lease(Path(project.repo_path))
236
+ if not lease.acquire():
237
+ raise WorkspaceLockedError(lock_path=lease.lock_path)
238
+
239
+ # 4. Delegate Session creation and lifecycle to the shared launcher
240
+ launcher = ProviderSessionLauncher(
241
+ process_runner=self._runner,
242
+ store=store,
243
+ checkpoint_service=self._checkpoint_service,
244
+ )
245
+ try:
246
+ session = launcher.start_session(
247
+ task_id=task.id,
248
+ provider_id=adapter.provider_id,
249
+ native_session_id=launch_spec.native_session_id,
250
+ )
251
+
252
+ def _forward(spec: LaunchSpecification, current: Session) -> None:
253
+ if on_launch:
254
+ on_launch(spec, current, task, project)
255
+
256
+ if str(adapter.provider_id) == str(
257
+ PROVIDER_ANTIGRAVITY
258
+ ) and not is_antigravity_mcp_configured(Path(project.repo_path)):
259
+ sys.stderr.write(f"\n{ANTIGRAVITY_MCP_MISSING_NOTICE}\n\n")
260
+
261
+ return launcher.run(
262
+ session,
263
+ launch_spec,
264
+ on_launch=_forward,
265
+ mcp_binder=adapter if isinstance(adapter, ManagedMcpBinder) else None,
266
+ )
267
+ finally:
268
+ lease.release()
269
+ finally:
270
+ store.close()
@@ -0,0 +1,183 @@
1
+ """Shared provider session lifecycle used by both `run` and `switch`.
2
+
3
+ Extracted from ``RunService`` in Phase 5 so that launching a native provider and
4
+ tracking its CortexShift Session lifecycle exists in exactly one place. The launcher
5
+ assumes its caller has already resolved the project and task and already holds the
6
+ exclusive workspace lease, which keeps `switch` from nesting a second advisory lock
7
+ around the same workspace.
8
+ """
9
+
10
+ import contextlib
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from cortexshift.domain.identifiers import utc_now
15
+ from cortexshift.domain.launch import LaunchSpecification
16
+ from cortexshift.domain.mcp_binding import McpSessionBinding
17
+ from cortexshift.domain.provider import ProviderId
18
+ from cortexshift.domain.session import Session, SessionExitReason, SessionStatus
19
+ from cortexshift.ports.process_runner import InteractiveProcessRunner
20
+ from cortexshift.ports.provider import ManagedMcpBinder
21
+ from cortexshift.ports.session_store import SessionStore
22
+
23
+
24
+ class ProviderSessionLauncher:
25
+ """Creates, runs, and finalizes CortexShift Sessions for native provider processes."""
26
+
27
+ def __init__(
28
+ self,
29
+ process_runner: InteractiveProcessRunner,
30
+ store: SessionStore,
31
+ checkpoint_service: Any | None = None,
32
+ ) -> None:
33
+ self._runner = process_runner
34
+ self._store = store
35
+ self._checkpoint_service = checkpoint_service
36
+
37
+ def start_session(
38
+ self,
39
+ task_id: str,
40
+ provider_id: ProviderId,
41
+ native_session_id: str | None = None,
42
+ metadata: dict[str, Any] | None = None,
43
+ resumed_from_session_id: str | None = None,
44
+ ) -> Session:
45
+ """Create and persist a running Session immediately before provider work begins."""
46
+ session = Session(
47
+ task_id=task_id,
48
+ provider_id=provider_id,
49
+ native_session_id=native_session_id,
50
+ resumed_from_session_id=resumed_from_session_id,
51
+ status=SessionStatus.RUNNING,
52
+ started_at=utc_now(),
53
+ metadata=metadata or {},
54
+ )
55
+ self._store.save_session(session)
56
+ return session
57
+
58
+ def attach_native_session_id(self, session: Session, native_session_id: str) -> Session:
59
+ """Bind a provider-native conversation identifier to an existing Session."""
60
+ updated = session.model_copy(update={"native_session_id": native_session_id})
61
+ self._store.save_session(updated)
62
+ return updated
63
+
64
+ def fail_session(
65
+ self,
66
+ session: Session,
67
+ exit_reason: SessionExitReason,
68
+ exit_code: int | None = None,
69
+ ) -> Session:
70
+ """Mark a Session failed before or instead of an interactive process run."""
71
+ updated = session.model_copy(
72
+ update={
73
+ "status": SessionStatus.FAILED,
74
+ "ended_at": utc_now(),
75
+ "exit_reason": exit_reason,
76
+ "exit_code": exit_code,
77
+ }
78
+ )
79
+ self._store.save_session(updated)
80
+ return updated
81
+
82
+ def run(
83
+ self,
84
+ session: Session,
85
+ launch_spec: LaunchSpecification,
86
+ on_launch: Callable[[LaunchSpecification, Session], None] | None = None,
87
+ mcp_binder: ManagedMcpBinder | None = None,
88
+ ) -> Session:
89
+ """Run the native provider process and finalize the Session lifecycle.
90
+
91
+ This is the only place a managed MCP binding is minted, and it is minted from the
92
+ persisted Session rather than from anything a provider or model supplied. Providers
93
+ that configure the CortexShift MCP server per launch declare the binding inside
94
+ that configuration, because a provider decides for itself how much of CortexShift's
95
+ environment the MCP server it spawns will actually see.
96
+
97
+ Generic non-zero exits map strictly to `process_crashed`; CortexShift never infers
98
+ quota exhaustion or rate limiting from an exit code. Spawn failures map to
99
+ `spawn_failed` and re-raise so the caller can classify the operation as failed.
100
+ """
101
+ try:
102
+ binding = McpSessionBinding.from_session(
103
+ session=session,
104
+ project_root=launch_spec.cwd,
105
+ )
106
+ if mcp_binder is not None:
107
+ launch_spec = mcp_binder.bind_managed_mcp(launch_spec, binding)
108
+
109
+ if on_launch:
110
+ on_launch(launch_spec, session)
111
+
112
+ # A launch specification may contribute environment of its own, but the trusted
113
+ # binding always wins: execution context is never negotiable by an adapter.
114
+ binding_env: dict[str, str] = dict(launch_spec.env)
115
+ binding_env.update(binding.to_env())
116
+
117
+ exit_code = self._runner.run_interactive(
118
+ argv=launch_spec.argv,
119
+ cwd=launch_spec.cwd,
120
+ env=binding_env,
121
+ )
122
+
123
+ ended_at = utc_now()
124
+ if exit_code == 0:
125
+ session = session.model_copy(
126
+ update={
127
+ "status": SessionStatus.COMPLETED,
128
+ "ended_at": ended_at,
129
+ "exit_code": 0,
130
+ "exit_reason": SessionExitReason.NORMAL_COMPLETION,
131
+ }
132
+ )
133
+ elif exit_code in (130, -2):
134
+ session = session.model_copy(
135
+ update={
136
+ "status": SessionStatus.INTERRUPTED,
137
+ "ended_at": ended_at,
138
+ "exit_code": 130,
139
+ "exit_reason": SessionExitReason.USER_INTERRUPTED,
140
+ }
141
+ )
142
+ else:
143
+ session = session.model_copy(
144
+ update={
145
+ "status": SessionStatus.FAILED,
146
+ "ended_at": ended_at,
147
+ "exit_code": exit_code,
148
+ "exit_reason": SessionExitReason.PROCESS_CRASHED,
149
+ }
150
+ )
151
+ except KeyboardInterrupt:
152
+ session = session.model_copy(
153
+ update={
154
+ "status": SessionStatus.INTERRUPTED,
155
+ "ended_at": utc_now(),
156
+ "exit_code": 130,
157
+ "exit_reason": SessionExitReason.USER_INTERRUPTED,
158
+ }
159
+ )
160
+ except Exception:
161
+ session = session.model_copy(
162
+ update={
163
+ "status": SessionStatus.FAILED,
164
+ "ended_at": utc_now(),
165
+ "exit_reason": SessionExitReason.SPAWN_FAILED,
166
+ }
167
+ )
168
+ self._store.save_session(session)
169
+ raise
170
+
171
+ self._store.save_session(session)
172
+
173
+ # Automatic session-end checkpoint capture for completed, failed, or interrupted runs
174
+ if self._checkpoint_service is not None and hasattr(
175
+ self._checkpoint_service, "capture_session_end_checkpoint"
176
+ ):
177
+ with contextlib.suppress(Exception):
178
+ self._checkpoint_service.capture_session_end_checkpoint(
179
+ session=session,
180
+ store=self._store,
181
+ )
182
+
183
+ return session
@@ -0,0 +1,63 @@
1
+ """Application service for querying CortexShift session history."""
2
+
3
+ from pathlib import Path
4
+
5
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
6
+ from cortexshift.application.locator import ProjectLocator
7
+ from cortexshift.domain.errors import ProjectNotInitializedError, SessionNotFoundError
8
+ from cortexshift.domain.session import Session
9
+
10
+
11
+ class SessionService:
12
+ """Provides querying capabilities for agent execution sessions."""
13
+
14
+ def __init__(self, project_locator: type[ProjectLocator] = ProjectLocator) -> None:
15
+ self._locator = project_locator
16
+
17
+ def list_sessions(
18
+ self,
19
+ start_dir: Path | str | None = None,
20
+ limit: int = 20,
21
+ ) -> list[Session]:
22
+ """List sessions for the current project, ordered newest first."""
23
+ start_path = Path(start_dir) if start_dir is not None else None
24
+ project_root = self._locator.find_project_root(start_path)
25
+ if project_root is None:
26
+ raise ProjectNotInitializedError()
27
+
28
+ db_path = self._locator.get_database_path(project_root)
29
+ store = SQLiteStateStore(db_path, auto_migrate=False)
30
+ try:
31
+ project = store.get_default_project()
32
+ if project is None:
33
+ raise ProjectNotInitializedError()
34
+
35
+ return store.list_sessions(project_id=project.id, limit=limit)
36
+ finally:
37
+ store.close()
38
+
39
+ def get_session(
40
+ self,
41
+ session_id: str,
42
+ start_dir: Path | str | None = None,
43
+ ) -> Session:
44
+ """Retrieve a specific session by ID.
45
+
46
+ Raises:
47
+ ProjectNotInitializedError: If no project is found.
48
+ SessionNotFoundError: If the session ID does not exist.
49
+ """
50
+ start_path = Path(start_dir) if start_dir is not None else None
51
+ project_root = self._locator.find_project_root(start_path)
52
+ if project_root is None:
53
+ raise ProjectNotInitializedError()
54
+
55
+ db_path = self._locator.get_database_path(project_root)
56
+ store = SQLiteStateStore(db_path, auto_migrate=False)
57
+ try:
58
+ session = store.get_session(session_id)
59
+ if session is None:
60
+ raise SessionNotFoundError(session_id)
61
+ return session
62
+ finally:
63
+ store.close()
@@ -0,0 +1,62 @@
1
+ """Selection of the CortexShift Session a handoff originates from.
2
+
3
+ Source selection reads only durable CortexShift session records. Provider transcript
4
+ history, hidden conversation storage, and provider-native session databases are never
5
+ consulted, and the outgoing provider is never contacted.
6
+ """
7
+
8
+ from cortexshift.domain.errors import (
9
+ NoSourceSessionError,
10
+ SessionNotFoundError,
11
+ SessionTaskMismatchError,
12
+ )
13
+ from cortexshift.domain.session import Session, SessionExitReason
14
+ from cortexshift.domain.task import Task
15
+ from cortexshift.ports.session_store import SessionStore
16
+
17
+ # How far back to look for a meaningful prior session on the active task.
18
+ _SOURCE_LOOKUP_LIMIT = 100
19
+
20
+
21
+ def _is_meaningful(session: Session) -> bool:
22
+ """Whether a session represents real prior agent work.
23
+
24
+ A session whose provider process never spawned contributed nothing, so it is not
25
+ preferred as a handoff source while an earlier real session exists.
26
+ """
27
+ return session.exit_reason != SessionExitReason.SPAWN_FAILED
28
+
29
+
30
+ def select_source_session(
31
+ store: SessionStore,
32
+ task: Task,
33
+ explicit_session_id: str | None = None,
34
+ ) -> Session:
35
+ """Resolve the source Session for a handoff on the given active task.
36
+
37
+ With no explicit override, the most recent meaningful session on the active task
38
+ wins, falling back to the most recent session overall when every recorded session
39
+ failed to spawn.
40
+
41
+ Raises:
42
+ SessionNotFoundError: If an explicitly requested session does not exist.
43
+ SessionTaskMismatchError: If an explicit session belongs to a different task.
44
+ NoSourceSessionError: If the active task has no prior session at all.
45
+ """
46
+ if explicit_session_id:
47
+ session = store.get_session(explicit_session_id)
48
+ if session is None:
49
+ raise SessionNotFoundError(explicit_session_id)
50
+ if session.task_id != task.id:
51
+ raise SessionTaskMismatchError(explicit_session_id, task.id)
52
+ return session
53
+
54
+ sessions = store.list_sessions(task_id=task.id, limit=_SOURCE_LOOKUP_LIMIT)
55
+ if not sessions:
56
+ raise NoSourceSessionError()
57
+
58
+ for session in sessions:
59
+ if _is_meaningful(session):
60
+ return session
61
+
62
+ return sessions[0]
@@ -0,0 +1,73 @@
1
+ """Application service for assembling project status and active task metrics."""
2
+
3
+ from pathlib import Path
4
+
5
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
6
+ from cortexshift.application.locator import (
7
+ DATABASE_FILE_NAME,
8
+ STATE_DIR_NAME,
9
+ ProjectLocator,
10
+ )
11
+ from cortexshift.domain.errors import ProjectNotInitializedError
12
+ from cortexshift.domain.status import ActiveTaskSummary, ProgressSummary, ProjectStatus
13
+
14
+
15
+ class ProjectStatusService:
16
+ """Queries project persistence to construct canonical status models."""
17
+
18
+ def __init__(self, locator: type[ProjectLocator] = ProjectLocator) -> None:
19
+ self.locator = locator
20
+
21
+ def get_status(self, start_path: Path | str | None = None) -> ProjectStatus:
22
+ """Inspect the project root for the given path and return structured status.
23
+
24
+ Args:
25
+ start_path: Starting path or project directory. Defaults to Path.cwd().
26
+
27
+ Returns:
28
+ A populated ProjectStatus entity.
29
+
30
+ Raises:
31
+ ProjectNotInitializedError: If no initialized project root is found.
32
+ DatabaseStateError: If the database is inaccessible or corrupted.
33
+ UnsupportedSchemaVersionError: If the database is at an incompatible schema version.
34
+ """
35
+ raw_path = Path(start_path) if start_path is not None else Path.cwd()
36
+ project_root = self.locator.find_project_root(raw_path)
37
+ if project_root is None:
38
+ raise ProjectNotInitializedError()
39
+
40
+ db_path = self.locator.get_database_path(project_root)
41
+ with SQLiteStateStore(db_path, auto_migrate=False) as store:
42
+ project = store.get_default_project()
43
+ if project is None:
44
+ raise ProjectNotInitializedError()
45
+
46
+ schema_version = store.get_schema_version()
47
+ active_id = store.get_active_task_id(project.id)
48
+
49
+ active_task_summary: ActiveTaskSummary | None = None
50
+ if active_id is not None:
51
+ active_task = store.get_task(active_id)
52
+ if active_task is not None:
53
+ active_task_summary = ActiveTaskSummary(
54
+ id=active_task.id,
55
+ title=active_task.title,
56
+ status=active_task.status,
57
+ objective=active_task.objective,
58
+ progress=ProgressSummary(
59
+ completed=len(active_task.completed_items),
60
+ remaining=len(active_task.remaining_items),
61
+ issues=len(active_task.known_issues),
62
+ ),
63
+ )
64
+
65
+ state_rel_path = f"{STATE_DIR_NAME}/{DATABASE_FILE_NAME}"
66
+ return ProjectStatus(
67
+ project_id=project.id,
68
+ name=project.name,
69
+ repo_path=project.repo_path,
70
+ state_file=state_rel_path,
71
+ schema_version=schema_version,
72
+ active_task=active_task_summary,
73
+ )