handback 0.2.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.
- handback/__init__.py +3 -0
- handback/__main__.py +3 -0
- handback/adapters/__init__.py +18 -0
- handback/adapters/antigravity.py +702 -0
- handback/adapters/base.py +44 -0
- handback/adapters/claude.py +38 -0
- handback/adapters/codex.py +471 -0
- handback/assets/icon-16.png +0 -0
- handback/assets/icon-256.png +0 -0
- handback/assets/icon-32.png +0 -0
- handback/assets/icon-48.png +0 -0
- handback/assets/icon-64.png +0 -0
- handback/assets/icon.ico +0 -0
- handback/cli.py +816 -0
- handback/collector.py +231 -0
- handback/config.py +182 -0
- handback/dashboard.py +1485 -0
- handback/diagnostics.py +326 -0
- handback/envelope.py +69 -0
- handback/hook_install.py +365 -0
- handback/hooks.py +472 -0
- handback/inbox.py +224 -0
- handback/invocation.py +18 -0
- handback/migration.py +383 -0
- handback/router.py +165 -0
- handback/skill_install.py +102 -0
- handback/skills/SKILL.md +60 -0
- handback/skills/antigravity/SKILL.md.in +3 -0
- handback/skills/codex/SKILL.md.in +3 -0
- handback/state.py +462 -0
- handback/state_transition.py +79 -0
- handback/watcher.py +84 -0
- handback-0.2.0.dist-info/METADATA +300 -0
- handback-0.2.0.dist-info/RECORD +38 -0
- handback-0.2.0.dist-info/WHEEL +5 -0
- handback-0.2.0.dist-info/entry_points.txt +5 -0
- handback-0.2.0.dist-info/licenses/LICENSE +21 -0
- handback-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""Antigravity app worker: one-shot sidecar delivery and transcript collection.
|
|
2
|
+
|
|
3
|
+
Facts this adapter relies on were measured in Phase 3.1
|
|
4
|
+
(docs/verification/2026-10-07-phase3-3.1.md); none has a published stability
|
|
5
|
+
guarantee except agentapi, sidecars and hooks themselves:
|
|
6
|
+
|
|
7
|
+
- agentapi runs only inside a sidecar. A relay-owned sidecar runs this package's
|
|
8
|
+
``antigravity-sidecar`` command once, which calls agentapi and records the result.
|
|
9
|
+
- ``new-conversation`` input is a USER_INPUT step; ``send-message`` input is a
|
|
10
|
+
SYSTEM_MESSAGE whose body starts with ``[Message]``. A Stop hook that continues
|
|
11
|
+
the loop adds a SYSTEM_MESSAGE starting with ``Stop hook blocked termination:``.
|
|
12
|
+
- Stop hook input has no reply text. The reply is the last tool-free
|
|
13
|
+
PLANNER_RESPONSE in transcript_full.jsonl.
|
|
14
|
+
- A fully idle Stop is final only when no other Stop hook can continue the loop,
|
|
15
|
+
or after the longest other Stop hook timeout passes without a continuation
|
|
16
|
+
(completion contract approved 2026-10-07).
|
|
17
|
+
"""
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from urllib.parse import unquote, urlparse
|
|
29
|
+
|
|
30
|
+
from .base import AdapterError, AdapterUnavailable, BaseAdapter
|
|
31
|
+
from .codex import BinaryRolloutTailer
|
|
32
|
+
from ..invocation import entry_args
|
|
33
|
+
|
|
34
|
+
SIDECAR_PREFIX = "handback-"
|
|
35
|
+
HOOK_GROUP = "handback"
|
|
36
|
+
HOOK_EVENTS = ("Stop", "PreInvocation")
|
|
37
|
+
DEFAULT_HOOK_TIMEOUT = 30
|
|
38
|
+
CONFIRM_MARGIN = 2.0
|
|
39
|
+
BLOCKED_PREFIX = "Stop hook blocked termination:"
|
|
40
|
+
MESSAGE_PREFIX = "[Message]"
|
|
41
|
+
ENTRY_SCRIPT = Path(__file__).resolve().parents[2] / "handback.py"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def utcnow():
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def gemini_home():
|
|
49
|
+
return Path(os.environ.get("GEMINI_HOME") or Path.home() / ".gemini")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _options():
|
|
53
|
+
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_json(path, default=None):
|
|
57
|
+
try:
|
|
58
|
+
raw = Path(path).read_bytes()
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
return default
|
|
61
|
+
text = raw.decode("utf-8-sig").strip()
|
|
62
|
+
return json.loads(text) if text else default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_json(path, value):
|
|
66
|
+
path = Path(path)
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
|
|
69
|
+
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
70
|
+
os.replace(temporary, path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _encode_like(original, value):
|
|
74
|
+
"""Serialize JSON keeping the original file's newline style and trailing newline."""
|
|
75
|
+
data = json.dumps(value, ensure_ascii=False, indent=2)
|
|
76
|
+
crlf = original is not None and b"\r\n" in original
|
|
77
|
+
if crlf:
|
|
78
|
+
data = data.replace("\n", "\r\n")
|
|
79
|
+
if original is None or original.endswith(b"\n"):
|
|
80
|
+
data += "\r\n" if crlf else "\n"
|
|
81
|
+
return data.encode("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def update_user_json(path, change, backup_dir):
|
|
85
|
+
"""Back up, change and atomically replace a user JSON file; refuse concurrent edits."""
|
|
86
|
+
path = Path(path)
|
|
87
|
+
before = path.read_bytes() if path.exists() else None
|
|
88
|
+
value = json.loads(before.decode("utf-8-sig") or "{}") if before is not None else {}
|
|
89
|
+
if not isinstance(value, dict):
|
|
90
|
+
raise AdapterError(f"Expected a JSON object in {path}")
|
|
91
|
+
changed = change(value)
|
|
92
|
+
if changed is False:
|
|
93
|
+
return None
|
|
94
|
+
backup = None
|
|
95
|
+
if before is not None:
|
|
96
|
+
backup = Path(backup_dir) / f"{path.name}.{datetime.now().strftime('%Y%m%dT%H%M%S%f')}.bak"
|
|
97
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
backup.write_bytes(before)
|
|
99
|
+
temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp")
|
|
100
|
+
temporary.write_bytes(_encode_like(before, value))
|
|
101
|
+
try:
|
|
102
|
+
current = path.read_bytes() if path.exists() else None
|
|
103
|
+
if current != before:
|
|
104
|
+
raise AdapterError(f"{path} changed concurrently; nothing written")
|
|
105
|
+
os.replace(temporary, path)
|
|
106
|
+
finally:
|
|
107
|
+
temporary.unlink(missing_ok=True)
|
|
108
|
+
return str(backup) if backup else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_project(root, home=None):
|
|
112
|
+
"""Return the Antigravity project ID whose git folder is this checkout."""
|
|
113
|
+
root = os.path.normcase(os.path.realpath(root))
|
|
114
|
+
matches = []
|
|
115
|
+
for path in sorted((home or gemini_home()).joinpath("config", "projects").glob("*.json")):
|
|
116
|
+
try:
|
|
117
|
+
project = _read_json(path, {})
|
|
118
|
+
except (OSError, ValueError):
|
|
119
|
+
continue
|
|
120
|
+
for resource in (project.get("projectResources") or {}).get("resources") or []:
|
|
121
|
+
uri = ((resource or {}).get("gitFolder") or {}).get("folderUri") or ""
|
|
122
|
+
if uri and os.path.normcase(os.path.realpath(unquote(urlparse(uri).path).lstrip("/"))) == root:
|
|
123
|
+
matches.append(path.stem)
|
|
124
|
+
if len(matches) != 1:
|
|
125
|
+
raise AdapterUnavailable(f"Expected one Antigravity project registered for {root}, found {len(matches)}; "
|
|
126
|
+
"register the checkout as a project in the Antigravity app")
|
|
127
|
+
return matches[0]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _hook_entries(value):
|
|
131
|
+
"""Yield (group_name, event, hook) from either documented or flat layouts."""
|
|
132
|
+
if not isinstance(value, dict):
|
|
133
|
+
return
|
|
134
|
+
for name, group in value.items():
|
|
135
|
+
if not isinstance(group, dict) or group.get("enabled", True) is False:
|
|
136
|
+
continue
|
|
137
|
+
for event, items in group.items():
|
|
138
|
+
if not isinstance(items, list):
|
|
139
|
+
continue
|
|
140
|
+
for item in items:
|
|
141
|
+
if isinstance(item, dict) and isinstance(item.get("hooks"), list):
|
|
142
|
+
for hook in item["hooks"]:
|
|
143
|
+
yield name, event, hook
|
|
144
|
+
elif isinstance(item, dict):
|
|
145
|
+
yield name, event, item
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def other_stop_hooks(root=None, home=None):
|
|
149
|
+
"""Stop hooks other than relay's that could continue a finished turn.
|
|
150
|
+
|
|
151
|
+
Returns (count, longest_timeout_seconds). An unreadable file counts as one
|
|
152
|
+
hook with the default timeout, because the relay cannot prove it is inert.
|
|
153
|
+
"""
|
|
154
|
+
home = home or gemini_home()
|
|
155
|
+
files = [home / "config" / "hooks.json"]
|
|
156
|
+
if root:
|
|
157
|
+
files.append(Path(root) / ".agents" / "hooks.json")
|
|
158
|
+
plugins = home / "config" / "plugins"
|
|
159
|
+
if plugins.is_dir():
|
|
160
|
+
files.extend(plugins.rglob("hooks.json"))
|
|
161
|
+
count, longest = 0, 0
|
|
162
|
+
for path in files:
|
|
163
|
+
try:
|
|
164
|
+
value = _read_json(path, {})
|
|
165
|
+
except (OSError, ValueError):
|
|
166
|
+
count, longest = count + 1, max(longest, DEFAULT_HOOK_TIMEOUT)
|
|
167
|
+
continue
|
|
168
|
+
for name, event, hook in _hook_entries(value):
|
|
169
|
+
if event != "Stop" or (name == HOOK_GROUP and path == home / "config" / "hooks.json"):
|
|
170
|
+
continue
|
|
171
|
+
count += 1
|
|
172
|
+
timeout = hook.get("timeout", DEFAULT_HOOK_TIMEOUT) if isinstance(hook, dict) else DEFAULT_HOOK_TIMEOUT
|
|
173
|
+
longest = max(longest, timeout if isinstance(timeout, (int, float)) and timeout > 0 else DEFAULT_HOOK_TIMEOUT)
|
|
174
|
+
return count, longest
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def observation_dir(home):
|
|
178
|
+
return Path(home) / "antigravity" / "observations"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def record_observation(home, payload, event):
|
|
182
|
+
"""Hook side: append a Stop/PreInvocation observation for one conversation."""
|
|
183
|
+
conversation = payload.get("conversationId")
|
|
184
|
+
if not isinstance(conversation, str) or not conversation or any(c in conversation for c in "/\\:.\0"):
|
|
185
|
+
return
|
|
186
|
+
keep = {"executionNum", "terminationReason", "error", "fullyIdle", "invocationNum", "initialNumSteps",
|
|
187
|
+
"deliveryReservation"}
|
|
188
|
+
record = {"at": time.time(), "event": event, **{k: payload.get(k) for k in keep if k in payload}}
|
|
189
|
+
directory = observation_dir(home)
|
|
190
|
+
path = directory / (conversation + ".jsonl")
|
|
191
|
+
if not path.exists():
|
|
192
|
+
# Only conversations the relay opened get a spool file; others are ignored.
|
|
193
|
+
return
|
|
194
|
+
with path.open("a", encoding="utf-8") as stream:
|
|
195
|
+
stream.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _observations(home, conversation):
|
|
199
|
+
path = observation_dir(home) / (conversation + ".jsonl")
|
|
200
|
+
result = []
|
|
201
|
+
try:
|
|
202
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
203
|
+
try:
|
|
204
|
+
result.append(json.loads(line))
|
|
205
|
+
except ValueError:
|
|
206
|
+
continue
|
|
207
|
+
except FileNotFoundError:
|
|
208
|
+
pass
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def lead_idle(home, conversation):
|
|
213
|
+
observations = _observations(home, conversation)
|
|
214
|
+
return bool(observations and observations[-1].get("event") == "Stop"
|
|
215
|
+
and observations[-1].get("fullyIdle") is True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def observe_lead(home, conversation):
|
|
219
|
+
if not isinstance(conversation, str) or not conversation or any(c in conversation for c in "/\\:.\0"):
|
|
220
|
+
raise ValueError("invalid Antigravity conversation id")
|
|
221
|
+
path = observation_dir(home) / (conversation + ".jsonl")
|
|
222
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
path.touch(exist_ok=True)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _epoch(value):
|
|
227
|
+
"""Transcript created_at (second resolution, UTC) as epoch seconds, or None."""
|
|
228
|
+
if not isinstance(value, str):
|
|
229
|
+
return None
|
|
230
|
+
try:
|
|
231
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
232
|
+
except ValueError:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def transcript_path(conversation, home=None):
|
|
237
|
+
return (home or gemini_home()) / "antigravity" / "brain" / conversation / ".system_generated" / "logs" / "transcript_full.jsonl"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _input_kind(record):
|
|
241
|
+
"""'input' for a new user/agentapi input, 'continue' for a hook continuation, else None."""
|
|
242
|
+
kind = record.get("type")
|
|
243
|
+
content = record.get("content") or ""
|
|
244
|
+
if kind == "USER_INPUT":
|
|
245
|
+
return "input"
|
|
246
|
+
if kind == "SYSTEM_MESSAGE":
|
|
247
|
+
body = content.rsplit("<SYSTEM_MESSAGE>", 1)[-1].lstrip()
|
|
248
|
+
if body.startswith(BLOCKED_PREFIX):
|
|
249
|
+
return "continue"
|
|
250
|
+
if body.startswith(MESSAGE_PREFIX):
|
|
251
|
+
# agentapi send-message arrives as sender=system. Background task
|
|
252
|
+
# notifications use sender=<conversation>/task-N and belong to the
|
|
253
|
+
# running turn (Phase 4.3: one ended a relay request early).
|
|
254
|
+
sender = re.search(r"\bsender=(\S+)", body.partition("\n")[0])
|
|
255
|
+
return "input" if sender and sender.group(1) == "system" else None
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class TranscriptCollector:
|
|
260
|
+
"""Incremental completion check for one marked Antigravity turn."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, conversation, marker, relay_home, root=None, gemini=None, clock=time.time):
|
|
263
|
+
self.conversation = conversation
|
|
264
|
+
self.marker = marker
|
|
265
|
+
self.relay_home = Path(relay_home)
|
|
266
|
+
self.root = root
|
|
267
|
+
self.gemini = gemini or gemini_home()
|
|
268
|
+
self.clock = clock
|
|
269
|
+
self.tailer = None
|
|
270
|
+
self.seen = False
|
|
271
|
+
self.marker_at = None
|
|
272
|
+
self.candidate = None
|
|
273
|
+
self.steps_after_candidate = 0
|
|
274
|
+
self.last_error = None
|
|
275
|
+
|
|
276
|
+
def _feed(self, record):
|
|
277
|
+
kind = _input_kind(record)
|
|
278
|
+
content = record.get("content") or ""
|
|
279
|
+
if not self.seen:
|
|
280
|
+
if kind == "input" and self.marker in content:
|
|
281
|
+
self.seen = True
|
|
282
|
+
self.marker_at = _epoch(record.get("created_at")) or self.clock()
|
|
283
|
+
return None
|
|
284
|
+
if kind == "input":
|
|
285
|
+
settled = self._settled_before(_epoch(record.get("created_at")))
|
|
286
|
+
if settled is not None:
|
|
287
|
+
return settled
|
|
288
|
+
return {"text": "", "outcome": "failed", "turn": None,
|
|
289
|
+
"error": "A later input arrived before the relay reply was confirmed; refusing to collect its answer"}
|
|
290
|
+
if (record.get("type") == "PLANNER_RESPONSE" and not record.get("tool_calls")
|
|
291
|
+
and content and record.get("status") == "DONE"):
|
|
292
|
+
self.candidate = content
|
|
293
|
+
self.steps_after_candidate = 0
|
|
294
|
+
else:
|
|
295
|
+
# Any later step (tool call, tool output, continuation) supersedes the candidate.
|
|
296
|
+
self.steps_after_candidate += 1
|
|
297
|
+
if record.get("status") == "ERROR":
|
|
298
|
+
self.last_error = content[:500] or "Antigravity step ended with ERROR"
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def poll(self):
|
|
302
|
+
if self.tailer is None:
|
|
303
|
+
path = transcript_path(self.conversation, self.gemini)
|
|
304
|
+
if not path.exists():
|
|
305
|
+
return None
|
|
306
|
+
self.tailer = BinaryRolloutTailer(path)
|
|
307
|
+
for record in self.tailer.read_records():
|
|
308
|
+
result = self._feed(record)
|
|
309
|
+
if result is not None:
|
|
310
|
+
return result
|
|
311
|
+
if not self.seen or self.marker_at is None:
|
|
312
|
+
return None
|
|
313
|
+
stops = [o for o in _observations(self.relay_home, self.conversation)
|
|
314
|
+
if o.get("event") == "Stop" and o.get("fullyIdle") is True and o.get("at", 0) >= self.marker_at - 1]
|
|
315
|
+
if not stops:
|
|
316
|
+
return None
|
|
317
|
+
stop = stops[-1]
|
|
318
|
+
later = [o for o in _observations(self.relay_home, self.conversation)
|
|
319
|
+
if o.get("event") == "PreInvocation" and o.get("at", 0) > stop["at"]]
|
|
320
|
+
if later:
|
|
321
|
+
return None # the loop resumed; wait for the next Stop
|
|
322
|
+
if self.candidate is None or self.steps_after_candidate:
|
|
323
|
+
reason = stop.get("terminationReason")
|
|
324
|
+
if stop.get("error") or (reason and reason != "NO_TOOL_CALL"):
|
|
325
|
+
outcome = {"text": "", "outcome": "failed", "turn": None,
|
|
326
|
+
"error": f"Antigravity stopped: {reason} {stop.get('error') or self.last_error or ''}".strip()}
|
|
327
|
+
return self._confirmed(stop, outcome)
|
|
328
|
+
return None
|
|
329
|
+
return self._confirmed(stop, {"text": self.candidate, "outcome": "completed", "turn": None})
|
|
330
|
+
|
|
331
|
+
def _settled_before(self, input_at):
|
|
332
|
+
"""The marked turn ended before a later input if a fully idle Stop came first.
|
|
333
|
+
|
|
334
|
+
Lets a collector that starts late (the next input is already in the
|
|
335
|
+
transcript) still collect. Input that arrives while the agent is busy is
|
|
336
|
+
injected into the running turn, so without an earlier idle Stop it fails.
|
|
337
|
+
A continuation would have added a transcript step after the candidate.
|
|
338
|
+
"""
|
|
339
|
+
if input_at is None or self.candidate is None or self.steps_after_candidate:
|
|
340
|
+
return None
|
|
341
|
+
# created_at has second resolution; observations are precise.
|
|
342
|
+
stops = [o for o in _observations(self.relay_home, self.conversation)
|
|
343
|
+
if o.get("event") == "Stop" and o.get("fullyIdle") is True
|
|
344
|
+
and self.marker_at - 1 <= o.get("at", 0) <= input_at + 1]
|
|
345
|
+
if not stops:
|
|
346
|
+
return None
|
|
347
|
+
return {"text": self.candidate, "outcome": "completed", "turn": None,
|
|
348
|
+
"confirmation": {"settled_before_next_input": True}}
|
|
349
|
+
|
|
350
|
+
def _confirmed(self, stop, outcome):
|
|
351
|
+
others, longest = other_stop_hooks(self.root, self.gemini)
|
|
352
|
+
if others and self.clock() < stop["at"] + longest + CONFIRM_MARGIN:
|
|
353
|
+
return None # another Stop hook may still continue this turn
|
|
354
|
+
outcome["confirmation"] = {"other_stop_hooks": others, "waited_for_timeout": longest if others else 0}
|
|
355
|
+
return outcome
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _short_path(path):
|
|
359
|
+
"""Windows 8.3 path when the long one has spaces; hook commands cannot be quoted."""
|
|
360
|
+
path = str(path)
|
|
361
|
+
if os.name != "nt" or " " not in path:
|
|
362
|
+
return path
|
|
363
|
+
import ctypes
|
|
364
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
365
|
+
if ctypes.windll.kernel32.GetShortPathNameW(path, buffer, len(buffer)):
|
|
366
|
+
return buffer.value
|
|
367
|
+
return path
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def hook_command(event, relay_home):
|
|
371
|
+
entry = [_short_path(p) if p not in ("-m", "handback") else p for p in entry_args(ENTRY_SCRIPT)]
|
|
372
|
+
parts = [_short_path(sys.executable), "-X", "utf8", *entry, "hook", "--agent", "antigravity",
|
|
373
|
+
"--event", event, "--state-home", _short_path(relay_home)]
|
|
374
|
+
if any(" " in part or '"' in part for part in parts):
|
|
375
|
+
raise AdapterUnavailable("Antigravity hook commands cannot be quoted; a path still contains a space: "
|
|
376
|
+
+ " ".join(parts))
|
|
377
|
+
return " ".join(parts)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _remove_legacy_hooks(value):
|
|
381
|
+
from ..hook_install import legacy_handler
|
|
382
|
+
group = value.get("agent-relay")
|
|
383
|
+
if not isinstance(group, dict):
|
|
384
|
+
return False
|
|
385
|
+
entries = list(_hook_entries({"agent-relay": {**group, "enabled": True}}))
|
|
386
|
+
if entries and all(legacy_handler(hook, "antigravity", event) for _, event, hook in entries):
|
|
387
|
+
del value["agent-relay"]
|
|
388
|
+
return True
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def install_hooks(relay_home, dry_run=False, home=None):
|
|
393
|
+
path = (home or gemini_home()) / "config" / "hooks.json"
|
|
394
|
+
group = {event: [{"type": "command", "command": hook_command(event, relay_home), "timeout": 10}]
|
|
395
|
+
for event in HOOK_EVENTS}
|
|
396
|
+
|
|
397
|
+
def change(value):
|
|
398
|
+
removed = _remove_legacy_hooks(value)
|
|
399
|
+
if value.get(HOOK_GROUP) == group:
|
|
400
|
+
return removed
|
|
401
|
+
value[HOOK_GROUP] = group
|
|
402
|
+
return True
|
|
403
|
+
|
|
404
|
+
if dry_run:
|
|
405
|
+
return {"path": str(path), "group": HOOK_GROUP, "add": group, "dry_run": True}
|
|
406
|
+
backup = update_user_json(path, change, Path(relay_home) / "antigravity" / "backups")
|
|
407
|
+
return {"path": str(path), "group": HOOK_GROUP, "add": group, "backup": backup, "changed": backup is not None}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def uninstall_hooks(relay_home, dry_run=False, home=None):
|
|
411
|
+
path = (home or gemini_home()) / "config" / "hooks.json"
|
|
412
|
+
if dry_run:
|
|
413
|
+
return {"path": str(path), "remove": HOOK_GROUP, "dry_run": True}
|
|
414
|
+
|
|
415
|
+
def change(value):
|
|
416
|
+
removed = _remove_legacy_hooks(value)
|
|
417
|
+
if HOOK_GROUP not in value:
|
|
418
|
+
return removed
|
|
419
|
+
value.pop(HOOK_GROUP)
|
|
420
|
+
return True
|
|
421
|
+
|
|
422
|
+
backup = update_user_json(path, change, Path(relay_home) / "antigravity" / "backups")
|
|
423
|
+
return {"path": str(path), "removed": HOOK_GROUP, "backup": backup, "changed": backup is not None}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def run_sidecar_job(job_path):
|
|
427
|
+
"""Body of the relay sidecar: one agentapi call, recorded, never repeated."""
|
|
428
|
+
job_path = Path(job_path)
|
|
429
|
+
job = _read_json(job_path)
|
|
430
|
+
folder = job_path.parent
|
|
431
|
+
try:
|
|
432
|
+
with (folder / "attempted").open("x", encoding="utf-8") as stream:
|
|
433
|
+
stream.write(utcnow())
|
|
434
|
+
except FileExistsError:
|
|
435
|
+
return 0
|
|
436
|
+
result = {"started": utcnow()}
|
|
437
|
+
try:
|
|
438
|
+
if os.environ.get("ANTIGRAVITY_PROJECT_ID") and os.environ["ANTIGRAVITY_PROJECT_ID"] != job["project_id"]:
|
|
439
|
+
raise AdapterError("sidecar runs in a different Antigravity project")
|
|
440
|
+
executable = os.environ.get("ANTIGRAVITY_AGENTAPI_EXE") or shutil.which("agentapi")
|
|
441
|
+
if not executable:
|
|
442
|
+
raise AdapterUnavailable("agentapi is not available inside the sidecar")
|
|
443
|
+
# Prefer the executable over agentapi.BAT: cmd would mangle quotes and newlines.
|
|
444
|
+
command = [executable] + ([] if Path(executable).stem.lower() == "agentapi" else ["agentapi"]) + job["args"]
|
|
445
|
+
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
446
|
+
timeout=120, **_options())
|
|
447
|
+
result.update(exit=completed.returncode, stdout=completed.stdout, stderr=completed.stderr)
|
|
448
|
+
except Exception as error:
|
|
449
|
+
result.update(exit=None, error=f"{type(error).__name__}: {error}")
|
|
450
|
+
result["finished"] = utcnow()
|
|
451
|
+
_write_json(folder / "result.json", result)
|
|
452
|
+
return 0
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def cleanup_sidecars(relay_home, dry_run=False, home=None):
|
|
456
|
+
"""Remove only provably owned, inactive sidecars with no open request."""
|
|
457
|
+
from ..collector import OPEN_STATES
|
|
458
|
+
home = home or gemini_home()
|
|
459
|
+
relay_home = Path(relay_home).resolve()
|
|
460
|
+
path = home / "config" / "config.json"
|
|
461
|
+
before = path.read_bytes() if path.exists() else None
|
|
462
|
+
value = _read_json(path, {})
|
|
463
|
+
candidates = []
|
|
464
|
+
manifests = {}
|
|
465
|
+
for name, entry in value.get("sidecars", {}).items():
|
|
466
|
+
if (not name.startswith((SIDECAR_PREFIX, "agent-relay-")) or Path(name).name != name or
|
|
467
|
+
any(c in name for c in "/\\:") or not isinstance(entry, dict) or
|
|
468
|
+
entry.get("enabled") is not False):
|
|
469
|
+
continue
|
|
470
|
+
folder = home / "config" / "sidecars" / name
|
|
471
|
+
if folder.is_symlink() or (hasattr(folder, "is_junction") and folder.is_junction()):
|
|
472
|
+
continue
|
|
473
|
+
manifest = folder / "sidecar.json"
|
|
474
|
+
try:
|
|
475
|
+
raw = manifest.read_bytes()
|
|
476
|
+
spec = json.loads(raw)
|
|
477
|
+
args = spec.get("args", [])
|
|
478
|
+
if not spec.get("description", "").startswith(("handback", "agent-relay")):
|
|
479
|
+
continue
|
|
480
|
+
legacy = (len(args) == 4 and Path(args[0]).resolve() == ENTRY_SCRIPT and
|
|
481
|
+
args[1:3] == ["antigravity-sidecar", "--job"])
|
|
482
|
+
module = len(args) == 5 and args[0] == "-m" and args[1] in ("handback", "agent_relay") and args[2:4] == ["antigravity-sidecar", "--job"]
|
|
483
|
+
if legacy or module:
|
|
484
|
+
job_path = Path(args[-1]).resolve()
|
|
485
|
+
job_path.relative_to(relay_home / "projects")
|
|
486
|
+
job = _read_json(job_path, {})
|
|
487
|
+
request_id = job.get("request_id")
|
|
488
|
+
from ..state import ID_PATTERN
|
|
489
|
+
if not isinstance(request_id, str) or not ID_PATTERN.fullmatch(request_id):
|
|
490
|
+
continue
|
|
491
|
+
if name not in (SIDECAR_PREFIX + request_id[:12], "agent-relay-" + request_id[:12]):
|
|
492
|
+
continue
|
|
493
|
+
request = _read_json(job_path.parents[2] / "requests" / (request_id + ".json"))
|
|
494
|
+
if request is not None and (not isinstance(request, dict) or
|
|
495
|
+
request.get("id") != request_id or request.get("status") in OPEN_STATES):
|
|
496
|
+
continue
|
|
497
|
+
else:
|
|
498
|
+
continue
|
|
499
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
500
|
+
continue
|
|
501
|
+
candidates.append(name)
|
|
502
|
+
manifests[name] = raw
|
|
503
|
+
result = {"candidates": candidates, "count": len(candidates), "dry_run": dry_run}
|
|
504
|
+
if dry_run or not candidates:
|
|
505
|
+
return result
|
|
506
|
+
backups = relay_home / "antigravity" / "backups"
|
|
507
|
+
stamp = datetime.now().strftime('%Y%m%dT%H%M%S%f')
|
|
508
|
+
folder_backup = backups / ("sidecars." + stamp)
|
|
509
|
+
for name in candidates:
|
|
510
|
+
shutil.copytree(home / "config" / "sidecars" / name, folder_backup / name)
|
|
511
|
+
|
|
512
|
+
def change(current):
|
|
513
|
+
if (cleanup_sidecars(relay_home, dry_run=True, home=home)["candidates"] != candidates or
|
|
514
|
+
path.read_bytes() != before or current != value or any(
|
|
515
|
+
(home / "config" / "sidecars" / name / "sidecar.json").read_bytes() != manifests[name]
|
|
516
|
+
for name in candidates)):
|
|
517
|
+
raise AdapterError("Sidecar configuration changed concurrently; nothing written")
|
|
518
|
+
for name in candidates:
|
|
519
|
+
current["sidecars"].pop(name)
|
|
520
|
+
|
|
521
|
+
result["backup"] = update_user_json(path, change, backups)
|
|
522
|
+
result["folder_backup"] = str(folder_backup)
|
|
523
|
+
for name in candidates:
|
|
524
|
+
folder = (home / "config" / "sidecars" / name).resolve()
|
|
525
|
+
folder.relative_to((home / "config" / "sidecars").resolve())
|
|
526
|
+
shutil.rmtree(folder)
|
|
527
|
+
result["removed"] = candidates
|
|
528
|
+
return result
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
class AntigravityAdapter(BaseAdapter):
|
|
532
|
+
name = "antigravity"
|
|
533
|
+
needs_context = True
|
|
534
|
+
|
|
535
|
+
def __init__(self, executable=None, model=None, delivery_timeout=60, gemini=None):
|
|
536
|
+
self.executable = executable
|
|
537
|
+
self.model = model or "flash"
|
|
538
|
+
self.delivery_timeout = delivery_timeout
|
|
539
|
+
self.gemini = gemini or gemini_home()
|
|
540
|
+
|
|
541
|
+
def _agentapi_help(self):
|
|
542
|
+
override = self.executable or os.environ.get("HANDBACK_ANTIGRAVITY") or os.environ.get("ANTIGRAVITY_AGENTAPI")
|
|
543
|
+
candidates = [override] if override else []
|
|
544
|
+
if not override:
|
|
545
|
+
roots = [os.environ.get("LOCALAPPDATA")]
|
|
546
|
+
roots.extend(str(Path(profile) / "AppData/Local") for profile in
|
|
547
|
+
(os.environ.get("USERPROFILE"), str(Path.home())) if profile)
|
|
548
|
+
candidates.extend(str(Path(root) / "Programs/antigravity/resources/bin/language_server.exe")
|
|
549
|
+
for root in roots if root)
|
|
550
|
+
for candidate in candidates:
|
|
551
|
+
if not candidate:
|
|
552
|
+
continue
|
|
553
|
+
command = [str(candidate)] + ([] if Path(candidate).stem.lower() == "agentapi" else ["agentapi"])
|
|
554
|
+
try:
|
|
555
|
+
result = subprocess.run(command + ["--help"], capture_output=True, text=True, encoding="utf-8",
|
|
556
|
+
errors="replace", timeout=15, **_options())
|
|
557
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
558
|
+
continue
|
|
559
|
+
if result.returncode == 0 and "new-conversation" in result.stdout:
|
|
560
|
+
return str(candidate)
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
def detect(self, root=None):
|
|
564
|
+
executable = self._agentapi_help()
|
|
565
|
+
sidecars = (_read_json(self.gemini / "config" / "config.json", {}) or {}).get("sidecars") or {}
|
|
566
|
+
own = [k for k in sidecars if k.startswith(SIDECAR_PREFIX)]
|
|
567
|
+
others, longest = other_stop_hooks(root, self.gemini)
|
|
568
|
+
hooks = _read_json(self.gemini / "config" / "hooks.json", {}) or {}
|
|
569
|
+
info = {"agent": self.name, "installed": executable is not None, "executable": executable, "version": None,
|
|
570
|
+
"warnings": [], "capabilities": {"worker": executable is not None, "lead": False,
|
|
571
|
+
"lead_implemented": True, "lead_deferred": True,
|
|
572
|
+
"hooks_installed": HOOK_GROUP in hooks},
|
|
573
|
+
"sidecars": {"relay_owned": len(own), "other": len(sidecars) - len(own),
|
|
574
|
+
"enabled": sum(1 for v in sidecars.values() if isinstance(v, dict) and v.get("enabled"))},
|
|
575
|
+
"other_stop_hooks": {"count": others, "longest_timeout": longest}}
|
|
576
|
+
if HOOK_GROUP not in hooks:
|
|
577
|
+
info["warnings"].append("relay Antigravity hooks are not installed; replies cannot be confirmed "
|
|
578
|
+
"(install-hooks --agents antigravity)")
|
|
579
|
+
if root:
|
|
580
|
+
try:
|
|
581
|
+
info["project_id"] = resolve_project(root, self.gemini)
|
|
582
|
+
except AdapterUnavailable as error:
|
|
583
|
+
info["warnings"].append(str(error))
|
|
584
|
+
return info
|
|
585
|
+
|
|
586
|
+
def new_thread(self, root, name, sandbox="workspace-write", open_app=True):
|
|
587
|
+
if sandbox == "read-only":
|
|
588
|
+
raise AdapterUnavailable("Antigravity cannot enforce a read-only sandbox; state read-only limits in the brief")
|
|
589
|
+
resolve_project(root, self.gemini)
|
|
590
|
+
# agentapi cannot create an empty conversation; the first send creates it.
|
|
591
|
+
return "pending-" + uuid.uuid4().hex
|
|
592
|
+
|
|
593
|
+
def deliver(self, thread, envelope, state=None, request=None, title=None):
|
|
594
|
+
if state is None or request is None:
|
|
595
|
+
raise AdapterError("Antigravity delivery needs the request state")
|
|
596
|
+
project_id = resolve_project(request.get("root") or state.root, self.gemini)
|
|
597
|
+
folder = state.path / "antigravity" / request["id"]
|
|
598
|
+
if thread.startswith("pending-"):
|
|
599
|
+
args = ["new-conversation", f"--title={title or 'handback'}", f"--model={self.model}", envelope["body"]]
|
|
600
|
+
else:
|
|
601
|
+
args = ["send-message", thread, envelope["body"]]
|
|
602
|
+
job = {"request_id": request["id"], "project_id": project_id, "args": args, "created": utcnow()}
|
|
603
|
+
_write_json(folder / "job.json", job)
|
|
604
|
+
sidecar = SIDECAR_PREFIX + request["id"][:12]
|
|
605
|
+
sidecar_dir = self.gemini / "config" / "sidecars" / sidecar
|
|
606
|
+
_write_json(sidecar_dir / "sidecar.json", {
|
|
607
|
+
"command": sys.executable, "args": [*entry_args(ENTRY_SCRIPT), "antigravity-sidecar", "--job", str(folder / "job.json")],
|
|
608
|
+
"restart_policy": "never", "display_name": "handback " + request["id"][:8],
|
|
609
|
+
"description": "handback: one agentapi call for one request, then exits."})
|
|
610
|
+
backups = state.home / "antigravity" / "backups"
|
|
611
|
+
config = self.gemini / "config" / "config.json"
|
|
612
|
+
|
|
613
|
+
def enable(value):
|
|
614
|
+
value.setdefault("sidecars", {})[sidecar] = {"enabled": True, "projectId": project_id}
|
|
615
|
+
|
|
616
|
+
settled = False
|
|
617
|
+
try:
|
|
618
|
+
update_user_json(config, enable, backups)
|
|
619
|
+
deadline = time.monotonic() + self.delivery_timeout
|
|
620
|
+
while time.monotonic() < deadline and not (folder / "result.json").exists():
|
|
621
|
+
time.sleep(0.25)
|
|
622
|
+
result = _read_json(folder / "result.json")
|
|
623
|
+
if result is None:
|
|
624
|
+
return {"accepted": False, "unknown": True,
|
|
625
|
+
"stderr": "Antigravity did not run the relay sidecar in time (is the app running?); "
|
|
626
|
+
"delivery is unknown; do not re-send"}
|
|
627
|
+
settled = True
|
|
628
|
+
if result.get("exit") is None:
|
|
629
|
+
return {"accepted": False, "unknown": True,
|
|
630
|
+
"stderr": result.get("error") or "sidecar outcome is unknown"}
|
|
631
|
+
if result.get("exit") != 0:
|
|
632
|
+
return {"accepted": False, "returncode": result.get("exit"),
|
|
633
|
+
"stderr": result.get("stderr") or result.get("error") or "agentapi failed",
|
|
634
|
+
"classification": self.classify_error(result.get("stderr") or result.get("error") or "")}
|
|
635
|
+
delivery = {"accepted": True, "returncode": 0}
|
|
636
|
+
if thread.startswith("pending-"):
|
|
637
|
+
try:
|
|
638
|
+
conversation = json.loads(result["stdout"])["response"]["newConversation"]["conversationId"]
|
|
639
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
640
|
+
raise AdapterError("agentapi did not return a conversation id") from error
|
|
641
|
+
delivery["thread"] = conversation
|
|
642
|
+
# Open the spool so the relay hook records observations for this conversation.
|
|
643
|
+
spool = observation_dir(state.home) / ((delivery.get("thread") or thread) + ".jsonl")
|
|
644
|
+
spool.parent.mkdir(parents=True, exist_ok=True)
|
|
645
|
+
spool.touch()
|
|
646
|
+
return delivery
|
|
647
|
+
finally:
|
|
648
|
+
def settle(value):
|
|
649
|
+
entry = value.get("sidecars", {}).get(sidecar)
|
|
650
|
+
if entry is None:
|
|
651
|
+
return False
|
|
652
|
+
if settled:
|
|
653
|
+
value["sidecars"].pop(sidecar) # relay-owned; nothing else references it
|
|
654
|
+
else:
|
|
655
|
+
entry["enabled"] = False # keep for diagnosis when the outcome is unknown
|
|
656
|
+
update_user_json(config, settle, backups)
|
|
657
|
+
if settled:
|
|
658
|
+
shutil.rmtree(sidecar_dir, ignore_errors=True)
|
|
659
|
+
|
|
660
|
+
def deliver_to_lead(self, thread, text, state=None):
|
|
661
|
+
if state is None:
|
|
662
|
+
return {"accepted": False, "pending": True}
|
|
663
|
+
with state.lock("lead:" + thread):
|
|
664
|
+
if not lead_idle(state.home, thread):
|
|
665
|
+
return {"accepted": False, "pending": True}
|
|
666
|
+
# Reserve idle BEFORE submission, so fast real Stop hooks win over
|
|
667
|
+
# this reservation and concurrent mail cannot reuse the same Stop.
|
|
668
|
+
reservation = uuid.uuid4().hex
|
|
669
|
+
record_observation(state.home, {"conversationId": thread, "deliveryReservation": reservation}, "PreInvocation")
|
|
670
|
+
request = {"id": uuid.uuid4().hex, "root": str(state.root)}
|
|
671
|
+
result = self.deliver(thread, {"body": text}, state=state, request=request)
|
|
672
|
+
observations = _observations(state.home, thread)
|
|
673
|
+
if (not result.get("accepted") and not result.get("unknown") and observations
|
|
674
|
+
and observations[-1].get("deliveryReservation") == reservation):
|
|
675
|
+
record_observation(state.home, {"conversationId": thread, "fullyIdle": True}, "Stop")
|
|
676
|
+
return result
|
|
677
|
+
|
|
678
|
+
def incremental_collector(self, request, relay_home=None):
|
|
679
|
+
from ..state import state_home
|
|
680
|
+
thread = request.get("thread")
|
|
681
|
+
if not thread or thread.startswith("pending-"):
|
|
682
|
+
raise ValueError("request has no Antigravity conversation yet")
|
|
683
|
+
return TranscriptCollector(thread, request["marker"], relay_home or state_home(),
|
|
684
|
+
root=request.get("root"), gemini=self.gemini)
|
|
685
|
+
|
|
686
|
+
def fallback_collect(self, request, timeout=0):
|
|
687
|
+
collector = self.incremental_collector(request)
|
|
688
|
+
deadline = time.time() + timeout if timeout else float("inf")
|
|
689
|
+
while True:
|
|
690
|
+
result = collector.poll()
|
|
691
|
+
if result is not None:
|
|
692
|
+
return result
|
|
693
|
+
if time.time() >= deadline:
|
|
694
|
+
return {"text": "", "outcome": "timeout",
|
|
695
|
+
"error": "Timed out waiting for the Antigravity reply; do not re-send"}
|
|
696
|
+
time.sleep(1)
|
|
697
|
+
|
|
698
|
+
def classify_error(self, text):
|
|
699
|
+
lowered = str(text).lower()
|
|
700
|
+
if "connect" in lowered and ("language server" in lowered or "ls_address" in lowered):
|
|
701
|
+
return "not_running"
|
|
702
|
+
return super().classify_error(text)
|