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,92 @@
|
|
|
1
|
+
"""Subprocess implementation of the HeadlessProviderRunner port."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cortexshift.ports.headless_runner import (
|
|
8
|
+
DEFAULT_HEADLESS_TIMEOUT_SECONDS,
|
|
9
|
+
HeadlessProviderRunner,
|
|
10
|
+
HeadlessResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Captured provider output is parsed for a handful of machine fields only. The bound
|
|
14
|
+
# stops a pathological provider from streaming unbounded output into memory; it is not
|
|
15
|
+
# a redaction mechanism, because the output is discarded rather than stored.
|
|
16
|
+
_MAX_CAPTURED_OUTPUT_CHARS = 2_000_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _bound(text: str | None) -> str:
|
|
20
|
+
"""Bound captured output length without altering its leading content."""
|
|
21
|
+
if not text:
|
|
22
|
+
return ""
|
|
23
|
+
if len(text) > _MAX_CAPTURED_OUTPUT_CHARS:
|
|
24
|
+
return text[:_MAX_CAPTURED_OUTPUT_CHARS]
|
|
25
|
+
return text
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SubprocessHeadlessProviderRunner(HeadlessProviderRunner):
|
|
29
|
+
"""Runs one non-interactive provider turn, capturing stdout and stderr.
|
|
30
|
+
|
|
31
|
+
Invariants:
|
|
32
|
+
- Never uses ``shell=True``; commands are pre-tokenized argument vectors.
|
|
33
|
+
- Requires no TTY and never inherits the user's terminal.
|
|
34
|
+
- Always enforces a finite (but model-turn appropriate) timeout.
|
|
35
|
+
- Never logs or prints captured output; callers parse and discard it.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, default_timeout: float = DEFAULT_HEADLESS_TIMEOUT_SECONDS) -> None:
|
|
39
|
+
self.default_timeout = default_timeout
|
|
40
|
+
|
|
41
|
+
def run_headless(
|
|
42
|
+
self,
|
|
43
|
+
argv: list[str],
|
|
44
|
+
cwd: Path | str,
|
|
45
|
+
timeout: float = DEFAULT_HEADLESS_TIMEOUT_SECONDS,
|
|
46
|
+
env: dict[str, str] | None = None,
|
|
47
|
+
) -> HeadlessResult:
|
|
48
|
+
"""Execute a bounded headless provider process without a shell or TTY."""
|
|
49
|
+
if not argv:
|
|
50
|
+
return HeadlessResult(
|
|
51
|
+
exit_code=1,
|
|
52
|
+
stdout="",
|
|
53
|
+
stderr="Empty command provided",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
full_env = dict(os.environ)
|
|
57
|
+
if env:
|
|
58
|
+
full_env.update(env)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
completed = subprocess.run(
|
|
62
|
+
argv,
|
|
63
|
+
cwd=str(cwd),
|
|
64
|
+
env=full_env,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
shell=False,
|
|
69
|
+
check=False,
|
|
70
|
+
stdin=subprocess.DEVNULL,
|
|
71
|
+
)
|
|
72
|
+
return HeadlessResult(
|
|
73
|
+
exit_code=completed.returncode,
|
|
74
|
+
stdout=_bound(completed.stdout),
|
|
75
|
+
stderr=_bound(completed.stderr),
|
|
76
|
+
)
|
|
77
|
+
except subprocess.TimeoutExpired:
|
|
78
|
+
return HeadlessResult(
|
|
79
|
+
exit_code=-1,
|
|
80
|
+
stdout="",
|
|
81
|
+
stderr="",
|
|
82
|
+
timed_out=True,
|
|
83
|
+
)
|
|
84
|
+
except (FileNotFoundError, PermissionError):
|
|
85
|
+
return HeadlessResult(
|
|
86
|
+
exit_code=127,
|
|
87
|
+
stdout="",
|
|
88
|
+
stderr="",
|
|
89
|
+
not_found=True,
|
|
90
|
+
)
|
|
91
|
+
except OSError:
|
|
92
|
+
return HeadlessResult(exit_code=1, stdout="", stderr="")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Subprocess implementation of the InteractiveProcessRunner port."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cortexshift.ports.process_runner import InteractiveProcessRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SubprocessInteractiveProcessRunner(InteractiveProcessRunner):
|
|
11
|
+
"""Executes native provider processes interactively inheriting terminal streams."""
|
|
12
|
+
|
|
13
|
+
def run_interactive(
|
|
14
|
+
self,
|
|
15
|
+
argv: list[str],
|
|
16
|
+
cwd: Path | str,
|
|
17
|
+
env: dict[str, str] | None = None,
|
|
18
|
+
) -> int:
|
|
19
|
+
"""Run a native provider process interactively without short timeouts.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
argv: Argument vector to execute directly (never through a shell).
|
|
23
|
+
cwd: Working directory (canonical project root).
|
|
24
|
+
env: Optional environment overlay.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
The exit code of the process (e.g. 0 on success, 130 on SIGINT).
|
|
28
|
+
"""
|
|
29
|
+
full_env = dict(os.environ)
|
|
30
|
+
if env:
|
|
31
|
+
full_env.update(env)
|
|
32
|
+
|
|
33
|
+
proc = None
|
|
34
|
+
try:
|
|
35
|
+
proc = subprocess.Popen(
|
|
36
|
+
argv,
|
|
37
|
+
cwd=str(cwd),
|
|
38
|
+
env=full_env,
|
|
39
|
+
stdin=None,
|
|
40
|
+
stdout=None,
|
|
41
|
+
stderr=None,
|
|
42
|
+
shell=False,
|
|
43
|
+
)
|
|
44
|
+
return proc.wait()
|
|
45
|
+
except KeyboardInterrupt:
|
|
46
|
+
if proc is not None:
|
|
47
|
+
try:
|
|
48
|
+
return proc.wait(timeout=2.0)
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
proc.terminate()
|
|
51
|
+
try:
|
|
52
|
+
return proc.wait(timeout=2.0)
|
|
53
|
+
except subprocess.TimeoutExpired:
|
|
54
|
+
proc.kill()
|
|
55
|
+
return proc.wait()
|
|
56
|
+
return 130
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Google Antigravity native CLI probe, runtime, and handoff delivery adapters."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from cortexshift.adapters.headless_runner import SubprocessHeadlessProviderRunner
|
|
11
|
+
from cortexshift.domain.doctor import AuthenticationStatus, ProviderDiagnostic
|
|
12
|
+
from cortexshift.domain.errors import (
|
|
13
|
+
CortexShiftError,
|
|
14
|
+
HandoffDeliveryError,
|
|
15
|
+
NativeResumeError,
|
|
16
|
+
UnsupportedPromptError,
|
|
17
|
+
)
|
|
18
|
+
from cortexshift.domain.handoff import HandoffFailureCode
|
|
19
|
+
from cortexshift.domain.launch import LaunchSpecification
|
|
20
|
+
from cortexshift.domain.native_session import NativeSessionCapabilities, valid_native_id
|
|
21
|
+
from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY, ProviderCapabilities, ProviderId
|
|
22
|
+
from cortexshift.ports.command_runner import CommandRunner
|
|
23
|
+
from cortexshift.ports.discovery import ProviderProbe
|
|
24
|
+
from cortexshift.ports.handoff_delivery import (
|
|
25
|
+
HandoffDeliveryPreparation,
|
|
26
|
+
HandoffDeliveryStrategy,
|
|
27
|
+
ProviderHandoffAdapter,
|
|
28
|
+
)
|
|
29
|
+
from cortexshift.ports.headless_runner import (
|
|
30
|
+
DEFAULT_HEADLESS_TIMEOUT_SECONDS,
|
|
31
|
+
HeadlessProviderRunner,
|
|
32
|
+
)
|
|
33
|
+
from cortexshift.ports.provider import ProviderRuntimeAdapter
|
|
34
|
+
|
|
35
|
+
_VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?(?:[-.][a-zA-Z0-9]+)?)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _extract_version(text: str) -> str | None:
|
|
39
|
+
"""Extract a clean semver-like version string from CLI output."""
|
|
40
|
+
match = _VERSION_RE.search(text)
|
|
41
|
+
if match:
|
|
42
|
+
return match.group(1)
|
|
43
|
+
first_line = text.splitlines()[0].strip() if text else ""
|
|
44
|
+
return first_line[:32] if first_line and len(first_line) <= 32 else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AntigravityProviderProbe(ProviderProbe):
|
|
48
|
+
"""Probe for detecting and diagnosing the Google Antigravity native CLI (`agy`).
|
|
49
|
+
|
|
50
|
+
CRITICAL INVARIANTS:
|
|
51
|
+
- Never invoke `agy -p ...` or headless prompt commands to check authentication.
|
|
52
|
+
Prompt execution consumes model quota and violates passive discovery.
|
|
53
|
+
- Never read config or credential files from ~/.gemini/... or Keychain.
|
|
54
|
+
- Since `agy` does not currently expose a documented passive, non-model auth status
|
|
55
|
+
command, authentication MUST be reported as UNKNOWN.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
command_runner: CommandRunner,
|
|
61
|
+
which_fn: Callable[[str], str | None] = shutil.which,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._runner = command_runner
|
|
64
|
+
self._which = which_fn
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def provider_id(self) -> ProviderId:
|
|
68
|
+
return PROVIDER_ANTIGRAVITY
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def display_name(self) -> str:
|
|
72
|
+
return "Antigravity"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def executable(self) -> str:
|
|
76
|
+
return "agy"
|
|
77
|
+
|
|
78
|
+
def get_capabilities(self) -> ProviderCapabilities:
|
|
79
|
+
return ProviderCapabilities(
|
|
80
|
+
provider_id=self.provider_id,
|
|
81
|
+
display_name=self.display_name,
|
|
82
|
+
supports_interactive=True,
|
|
83
|
+
supports_headless=True,
|
|
84
|
+
supports_native_resume=True,
|
|
85
|
+
supports_structured_output=True,
|
|
86
|
+
supports_mcp=True,
|
|
87
|
+
supports_usage_metrics=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def probe(self) -> ProviderDiagnostic:
|
|
91
|
+
capabilities = self.get_capabilities()
|
|
92
|
+
resolved_path = self._which(self.executable)
|
|
93
|
+
|
|
94
|
+
if not resolved_path:
|
|
95
|
+
return ProviderDiagnostic(
|
|
96
|
+
provider_id=self.provider_id,
|
|
97
|
+
display_name=self.display_name,
|
|
98
|
+
executable=self.executable,
|
|
99
|
+
installed=False,
|
|
100
|
+
resolved_path=None,
|
|
101
|
+
version=None,
|
|
102
|
+
authentication_status=AuthenticationStatus.UNKNOWN,
|
|
103
|
+
capabilities=capabilities,
|
|
104
|
+
diagnostics=["Not found in PATH"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# 1. Version probe (passive --version)
|
|
108
|
+
version_result = self._runner.run([resolved_path, "--version"], timeout=5.0)
|
|
109
|
+
version: str | None = None
|
|
110
|
+
diagnostics: list[str] = []
|
|
111
|
+
|
|
112
|
+
if version_result.success:
|
|
113
|
+
version = _extract_version(version_result.stdout)
|
|
114
|
+
if not version:
|
|
115
|
+
diagnostics.append("Version output could not be parsed")
|
|
116
|
+
else:
|
|
117
|
+
if version_result.timed_out:
|
|
118
|
+
diagnostics.append("Version probe timed out")
|
|
119
|
+
else:
|
|
120
|
+
diagnostics.append("Version probe failed")
|
|
121
|
+
|
|
122
|
+
# 2. Authentication probe:
|
|
123
|
+
# Passive-only: agy does not offer a non-model auth status command.
|
|
124
|
+
# Report UNKNOWN without running any prompts.
|
|
125
|
+
diagnostics.append("Passive authentication probe not supported")
|
|
126
|
+
|
|
127
|
+
return ProviderDiagnostic(
|
|
128
|
+
provider_id=self.provider_id,
|
|
129
|
+
display_name=self.display_name,
|
|
130
|
+
executable=self.executable,
|
|
131
|
+
installed=True,
|
|
132
|
+
resolved_path=resolved_path,
|
|
133
|
+
version=version,
|
|
134
|
+
authentication_status=AuthenticationStatus.UNKNOWN,
|
|
135
|
+
capabilities=capabilities,
|
|
136
|
+
diagnostics=diagnostics,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class AntigravityRuntimeAdapter(ProviderRuntimeAdapter):
|
|
141
|
+
"""Runtime adapter for launching Google Antigravity interactive sessions."""
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def provider_id(self) -> ProviderId:
|
|
145
|
+
return PROVIDER_ANTIGRAVITY
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def display_name(self) -> str:
|
|
149
|
+
return "Antigravity"
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def executable(self) -> str:
|
|
153
|
+
return "agy"
|
|
154
|
+
|
|
155
|
+
def get_capabilities(self) -> ProviderCapabilities:
|
|
156
|
+
return ProviderCapabilities(
|
|
157
|
+
provider_id=self.provider_id,
|
|
158
|
+
display_name=self.display_name,
|
|
159
|
+
supports_interactive=True,
|
|
160
|
+
supports_headless=True,
|
|
161
|
+
supports_native_resume=True,
|
|
162
|
+
supports_structured_output=True,
|
|
163
|
+
supports_mcp=True,
|
|
164
|
+
supports_usage_metrics=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def get_native_capabilities(self) -> NativeSessionCapabilities:
|
|
168
|
+
return NativeSessionCapabilities(
|
|
169
|
+
supports_exact_resume=True,
|
|
170
|
+
can_capture_native_id_during_bootstrap=True,
|
|
171
|
+
can_resume_with_followup_context=True,
|
|
172
|
+
requires_model_turn_for_handoff_resume=True,
|
|
173
|
+
supports_managed_new_session=True,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def build_exact_resume(
|
|
177
|
+
self, project_root: Path, executable_path: str, native_session_id: str
|
|
178
|
+
) -> LaunchSpecification:
|
|
179
|
+
if not valid_native_id(native_session_id):
|
|
180
|
+
raise NativeResumeError("Invalid native session identifier.")
|
|
181
|
+
return LaunchSpecification(
|
|
182
|
+
provider_id=self.provider_id,
|
|
183
|
+
executable=executable_path,
|
|
184
|
+
cwd=project_root,
|
|
185
|
+
argv=[executable_path, "--conversation", native_session_id],
|
|
186
|
+
native_session_id=native_session_id,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def build_launch_spec(
|
|
190
|
+
self,
|
|
191
|
+
project_root: Path,
|
|
192
|
+
executable_path: str,
|
|
193
|
+
prompt: str | None = None,
|
|
194
|
+
) -> LaunchSpecification:
|
|
195
|
+
"""Build argument vector for native Antigravity launch.
|
|
196
|
+
|
|
197
|
+
Raises UnsupportedPromptError if an initial prompt is provided.
|
|
198
|
+
"""
|
|
199
|
+
if prompt is not None and prompt.strip():
|
|
200
|
+
raise UnsupportedPromptError(
|
|
201
|
+
"Antigravity does not currently expose a supported interactive "
|
|
202
|
+
"initial-prompt launch path through CortexShift.\n\n"
|
|
203
|
+
"Launch without --prompt and enter the prompt in the native Antigravity UI."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return LaunchSpecification(
|
|
207
|
+
provider_id=self.provider_id,
|
|
208
|
+
executable=executable_path,
|
|
209
|
+
cwd=project_root,
|
|
210
|
+
argv=[executable_path],
|
|
211
|
+
interactive=True,
|
|
212
|
+
initial_prompt_supported=False,
|
|
213
|
+
prompt_supplied=False,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
ANTIGRAVITY_BOOTSTRAP_PREFIX = """This is a CortexShift handoff bootstrap.
|
|
218
|
+
|
|
219
|
+
Analyze and ingest the task context.
|
|
220
|
+
Remain read-only.
|
|
221
|
+
Do not modify files.
|
|
222
|
+
Do not run mutating commands.
|
|
223
|
+
Produce a concise continuation plan.
|
|
224
|
+
|
|
225
|
+
The same conversation will be resumed immediately
|
|
226
|
+
in the native interactive Antigravity UI.
|
|
227
|
+
|
|
228
|
+
--------------------------------------------------
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_bootstrap_metadata(stdout: str) -> tuple[str | None, str | None]:
|
|
233
|
+
"""Extract only the required machine fields from Antigravity's JSON bootstrap output.
|
|
234
|
+
|
|
235
|
+
Returns a `(conversation_id, status)` tuple. The provider response body, reasoning,
|
|
236
|
+
usage, and tool details are intentionally not read, returned, logged, or persisted.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
HandoffDeliveryError: If the output is not parseable structured JSON.
|
|
240
|
+
"""
|
|
241
|
+
text = stdout.strip()
|
|
242
|
+
if not text:
|
|
243
|
+
raise HandoffDeliveryError(
|
|
244
|
+
HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
|
|
245
|
+
"The Antigravity handoff bootstrap returned no structured output.",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
data = json.loads(text)
|
|
250
|
+
except json.JSONDecodeError as err:
|
|
251
|
+
raise HandoffDeliveryError(
|
|
252
|
+
HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
|
|
253
|
+
"The Antigravity handoff bootstrap did not return parseable JSON output.",
|
|
254
|
+
) from err
|
|
255
|
+
|
|
256
|
+
if not isinstance(data, dict):
|
|
257
|
+
raise HandoffDeliveryError(
|
|
258
|
+
HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
|
|
259
|
+
"The Antigravity handoff bootstrap returned an unexpected JSON structure.",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
raw_conversation_id = data.get("conversation_id")
|
|
263
|
+
conversation_id = raw_conversation_id.strip() if isinstance(raw_conversation_id, str) else None
|
|
264
|
+
|
|
265
|
+
raw_status = data.get("status")
|
|
266
|
+
status = raw_status.strip() if isinstance(raw_status, str) else None
|
|
267
|
+
|
|
268
|
+
return (conversation_id or None), status
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class AntigravityHandoffAdapter(ProviderHandoffAdapter):
|
|
272
|
+
"""Delivers canonical handoff context to Antigravity via a safe two-stage flow.
|
|
273
|
+
|
|
274
|
+
Antigravity's documented native interactive startup does not expose the same direct
|
|
275
|
+
positional initial-prompt path as Claude Code and Codex. Rather than emulating
|
|
276
|
+
keystrokes, scraping the TUI, or silently dropping the handoff, CortexShift uses two
|
|
277
|
+
documented native capabilities:
|
|
278
|
+
|
|
279
|
+
1. A read-only headless planning turn (`--mode=plan -p <context> --output-format json`)
|
|
280
|
+
that ingests the canonical context and produces a continuation plan. Plan mode is
|
|
281
|
+
required here because the purpose of the first turn is context ingestion and
|
|
282
|
+
planning, never unattended workspace mutation.
|
|
283
|
+
2. An interactive resume of that same conversation (`--conversation <id>`) so the user
|
|
284
|
+
lands in the native TUI with the handoff already loaded.
|
|
285
|
+
|
|
286
|
+
Only `conversation_id` and `status` are read from the bootstrap output; the response
|
|
287
|
+
body is discarded. Permission bypass flags are never used.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(
|
|
291
|
+
self,
|
|
292
|
+
headless_runner: HeadlessProviderRunner | None = None,
|
|
293
|
+
runtime_adapter: "AntigravityRuntimeAdapter | None" = None,
|
|
294
|
+
timeout: float = DEFAULT_HEADLESS_TIMEOUT_SECONDS,
|
|
295
|
+
) -> None:
|
|
296
|
+
self._headless = headless_runner or SubprocessHeadlessProviderRunner()
|
|
297
|
+
self._runtime = runtime_adapter or AntigravityRuntimeAdapter()
|
|
298
|
+
self._timeout = timeout
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def provider_id(self) -> ProviderId:
|
|
302
|
+
return PROVIDER_ANTIGRAVITY
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def display_name(self) -> str:
|
|
306
|
+
return self._runtime.display_name
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def executable(self) -> str:
|
|
310
|
+
return self._runtime.executable
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def delivery_strategy(self) -> HandoffDeliveryStrategy:
|
|
314
|
+
return HandoffDeliveryStrategy.PLAN_BOOTSTRAP_THEN_RESUME
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def bootstrap_model_turn_required(self) -> bool:
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
def prepare_delivery(
|
|
321
|
+
self,
|
|
322
|
+
executable_path: str,
|
|
323
|
+
project_root: Path,
|
|
324
|
+
rendered_context: str,
|
|
325
|
+
native_session_id: str | None = None,
|
|
326
|
+
) -> HandoffDeliveryPreparation:
|
|
327
|
+
"""Run the read-only plan bootstrap, then prepare interactive conversation resume."""
|
|
328
|
+
if native_session_id is not None and not valid_native_id(native_session_id):
|
|
329
|
+
raise NativeResumeError("Invalid native session identifier.")
|
|
330
|
+
bootstrap_prompt = f"{ANTIGRAVITY_BOOTSTRAP_PREFIX}\n{rendered_context}"
|
|
331
|
+
|
|
332
|
+
result = self._headless.run_headless(
|
|
333
|
+
argv=[
|
|
334
|
+
executable_path,
|
|
335
|
+
"--mode=plan",
|
|
336
|
+
"-p",
|
|
337
|
+
bootstrap_prompt,
|
|
338
|
+
"--output-format",
|
|
339
|
+
"json",
|
|
340
|
+
*(["--conversation", native_session_id] if native_session_id else []),
|
|
341
|
+
],
|
|
342
|
+
cwd=project_root,
|
|
343
|
+
timeout=self._timeout,
|
|
344
|
+
env={"CORTEXSHIFT_MCP_READ_ONLY": "1"},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if result.timed_out:
|
|
348
|
+
raise HandoffDeliveryError(
|
|
349
|
+
HandoffFailureCode.BOOTSTRAP_TIMEOUT.value,
|
|
350
|
+
"The Antigravity handoff bootstrap timed out before returning a plan.",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if result.not_found:
|
|
354
|
+
raise HandoffDeliveryError(
|
|
355
|
+
HandoffFailureCode.SPAWN_FAILED.value,
|
|
356
|
+
"The Antigravity executable could not be started for the handoff bootstrap.",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if result.exit_code != 0:
|
|
360
|
+
raise HandoffDeliveryError(
|
|
361
|
+
HandoffFailureCode.BOOTSTRAP_FAILED.value,
|
|
362
|
+
"The Antigravity handoff bootstrap exited unsuccessfully.",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
conversation_id, status = _parse_bootstrap_metadata(result.stdout)
|
|
366
|
+
|
|
367
|
+
if status != "SUCCESS":
|
|
368
|
+
raise HandoffDeliveryError(
|
|
369
|
+
HandoffFailureCode.BOOTSTRAP_FAILED.value,
|
|
370
|
+
"The Antigravity handoff bootstrap did not report a successful status.",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if not valid_native_id(conversation_id):
|
|
374
|
+
raise HandoffDeliveryError(
|
|
375
|
+
HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
|
|
376
|
+
"The Antigravity handoff bootstrap did not return a conversation identifier.",
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
assert conversation_id is not None
|
|
380
|
+
if native_session_id is not None and conversation_id != native_session_id:
|
|
381
|
+
raise HandoffDeliveryError(
|
|
382
|
+
HandoffFailureCode.BOOTSTRAP_INVALID_OUTPUT.value,
|
|
383
|
+
"Antigravity returned a different conversation; refusing continuity fallback.",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
launch_spec = LaunchSpecification(
|
|
387
|
+
provider_id=self.provider_id,
|
|
388
|
+
executable=executable_path,
|
|
389
|
+
cwd=project_root,
|
|
390
|
+
argv=[executable_path, "--conversation", conversation_id],
|
|
391
|
+
native_session_id=conversation_id,
|
|
392
|
+
interactive=True,
|
|
393
|
+
initial_prompt_supported=False,
|
|
394
|
+
prompt_supplied=False,
|
|
395
|
+
metadata={"resumed_conversation": True},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return HandoffDeliveryPreparation(
|
|
399
|
+
launch_spec=launch_spec,
|
|
400
|
+
native_session_id=conversation_id,
|
|
401
|
+
bootstrap_performed=True,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
ANTIGRAVITY_MCP_CONFIG_REL_PATH = Path(".agents/mcp_config.json")
|
|
406
|
+
|
|
407
|
+
CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER = {
|
|
408
|
+
"command": "cortexshift",
|
|
409
|
+
"args": ["mcp", "serve"],
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
ANTIGRAVITY_MCP_MISSING_NOTICE = (
|
|
413
|
+
"CortexShift MCP is not configured for Antigravity in this workspace.\n\n"
|
|
414
|
+
"Run:\n cortexshift mcp setup antigravity"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def is_antigravity_mcp_configured(project_root: Path) -> bool:
|
|
419
|
+
"""Check if Antigravity workspace MCP config contains the cortexshift server."""
|
|
420
|
+
config_path = project_root / ANTIGRAVITY_MCP_CONFIG_REL_PATH
|
|
421
|
+
if not config_path.is_file():
|
|
422
|
+
return False
|
|
423
|
+
try:
|
|
424
|
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
|
425
|
+
if not isinstance(data, dict):
|
|
426
|
+
return False
|
|
427
|
+
servers = data.get("mcpServers")
|
|
428
|
+
if not isinstance(servers, dict):
|
|
429
|
+
return False
|
|
430
|
+
entry = servers.get("cortexshift")
|
|
431
|
+
return (
|
|
432
|
+
isinstance(entry, dict)
|
|
433
|
+
and entry.get("command") == CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER["command"]
|
|
434
|
+
and entry.get("args") == CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER["args"]
|
|
435
|
+
)
|
|
436
|
+
except Exception:
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def setup_antigravity_mcp(
|
|
441
|
+
project_root: Path,
|
|
442
|
+
dry_run: bool = False,
|
|
443
|
+
force: bool = False,
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""Safely configure project-local .agents/mcp_config.json for Antigravity.
|
|
446
|
+
|
|
447
|
+
Preserves unrelated MCP servers and top-level keys.
|
|
448
|
+
Fails on configuration conflicts unless force=True.
|
|
449
|
+
Supports dry_run without modifying the filesystem.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
A dictionary describing the action performed and configuration details.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
CortexShiftError: If the existing file contains invalid JSON or has a conflict.
|
|
456
|
+
"""
|
|
457
|
+
config_path = project_root / ANTIGRAVITY_MCP_CONFIG_REL_PATH
|
|
458
|
+
target_entry = dict(CORTEXSHIFT_ANTIGRAVITY_MCP_SERVER)
|
|
459
|
+
|
|
460
|
+
if not config_path.is_file():
|
|
461
|
+
new_data: dict[str, Any] = {
|
|
462
|
+
"mcpServers": {
|
|
463
|
+
"cortexshift": target_entry,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if not dry_run:
|
|
467
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
468
|
+
config_path.write_text(json.dumps(new_data, indent=2) + "\n", encoding="utf-8")
|
|
469
|
+
return {
|
|
470
|
+
"action": "created",
|
|
471
|
+
"path": str(config_path),
|
|
472
|
+
"changed": True,
|
|
473
|
+
"dry_run": dry_run,
|
|
474
|
+
"servers": ["cortexshift"],
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
# File exists: read and parse safely
|
|
478
|
+
raw_content = config_path.read_text(encoding="utf-8")
|
|
479
|
+
try:
|
|
480
|
+
data = json.loads(raw_content)
|
|
481
|
+
except json.JSONDecodeError as err:
|
|
482
|
+
raise CortexShiftError(
|
|
483
|
+
f"Cannot safely configure MCP: {config_path} contains invalid JSON."
|
|
484
|
+
) from err
|
|
485
|
+
|
|
486
|
+
if not isinstance(data, dict):
|
|
487
|
+
raise CortexShiftError(
|
|
488
|
+
f"Cannot safely configure MCP: {config_path} must contain a JSON object."
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
mcp_servers = data.get("mcpServers")
|
|
492
|
+
if mcp_servers is None:
|
|
493
|
+
mcp_servers = {}
|
|
494
|
+
data["mcpServers"] = mcp_servers
|
|
495
|
+
elif not isinstance(mcp_servers, dict):
|
|
496
|
+
raise CortexShiftError(
|
|
497
|
+
f"Cannot safely configure MCP: 'mcpServers' in {config_path} must be a JSON object."
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
existing_entry = mcp_servers.get("cortexshift")
|
|
501
|
+
if existing_entry == target_entry:
|
|
502
|
+
return {
|
|
503
|
+
"action": "noop",
|
|
504
|
+
"path": str(config_path),
|
|
505
|
+
"changed": False,
|
|
506
|
+
"dry_run": dry_run,
|
|
507
|
+
"servers": list(mcp_servers.keys()),
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if existing_entry is not None and not force:
|
|
511
|
+
raise CortexShiftError(
|
|
512
|
+
f"Conflicting configuration for 'cortexshift' already exists in {config_path}.\n"
|
|
513
|
+
"Use --force to overwrite only the 'cortexshift' entry while preserving other servers."
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
mcp_servers["cortexshift"] = target_entry
|
|
517
|
+
|
|
518
|
+
if not dry_run:
|
|
519
|
+
# Atomic write: write to temp file then replace
|
|
520
|
+
temp_path = config_path.with_suffix(".tmp")
|
|
521
|
+
temp_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
522
|
+
temp_path.replace(config_path)
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
"action": "updated",
|
|
526
|
+
"path": str(config_path),
|
|
527
|
+
"changed": True,
|
|
528
|
+
"dry_run": dry_run,
|
|
529
|
+
"servers": list(mcp_servers.keys()),
|
|
530
|
+
}
|