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.
@@ -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)