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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|