antimatter-ag2 1.0.0__tar.gz

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,31 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ site/
5
+ .DS_Store
6
+ *.log
7
+ .env
8
+ reports/
9
+ .vscode/
10
+ .agents/
11
+ outputs/
12
+ .astro/
13
+
14
+ # Python
15
+ __pycache__/
16
+ *.py[cod]
17
+ *$py.class
18
+ *.so
19
+ .Python
20
+ env/
21
+ venv/
22
+ .venv/
23
+ pip-log.txt
24
+ pip-delete-this-directory.txt
25
+ .pytest_cache/
26
+ *.egg-info/
27
+ .hatch/
28
+
29
+ # VSIX / Extension Manifests
30
+ *.vsix
31
+ extension.vsixmanifest
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saif Mukhtar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: antimatter-ag2
3
+ Version: 1.0.0
4
+ Summary: Antimatter AG2 Adapter — companion daemon for Google AntiGravity IDE
5
+ Project-URL: Homepage, https://saifmukhtar.dev
6
+ Project-URL: Repository, https://github.com/saifmukhtar/antimatter
7
+ Project-URL: Issues, https://github.com/saifmukhtar/antimatter/issues
8
+ Project-URL: Documentation, https://saifmukhtar.dev/docs/antimatter
9
+ Project-URL: Changelog, https://github.com/saifmukhtar/antimatter/releases
10
+ Author-email: Saif Mukhtar <saifmukhtar@saifmukhtar.dev>
11
+ License: MIT License
12
+
13
+ Copyright (c) 2026 Saif Mukhtar
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+ License-File: LICENSE
33
+ Keywords: ai,android,antigravity,bridge,ide,mobile,websocket
34
+ Classifier: Development Status :: 4 - Beta
35
+ Classifier: Environment :: Console
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Topic :: Communications
39
+ Classifier: Topic :: Software Development
40
+ Requires-Python: >=3.11
41
+ Requires-Dist: google-antigravity==0.1.2
42
+ Requires-Dist: websockets==15.0.1
43
+ Provides-Extra: test
44
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
45
+ Requires-Dist: pytest>=7.0.0; extra == 'test'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # Antimatter: Antigravity 2.0 Adapter (AG2)
49
+
50
+ This directory contains the Antimatter IPC adapter for the Antigravity 2.0 SDK CLI.
51
+
52
+ ## Architecture
53
+
54
+ This adapter follows the **Independent Adapter Model**. It does NOT contain any complex networking, Cloudflare tunnels, or cryptographic pairing logic. Instead, it acts purely as a "dumb" IPC client that connects to the central Antimatter Gateway.
55
+
56
+ 1. **Connection**: The daemon connects locally to the Gateway via WebSocket at `ws://127.0.0.1:8765`.
57
+ 2. **Registration**: Upon connection, it sends `{"type": "REGISTER_ADAPTER", "name": "ag2"}`.
58
+ 3. **Execution**: When you send a message from the Antimatter Android app targeting AG2, the Gateway securely routes that payload to this adapter. This adapter interfaces with the Antigravity 2.0 SDK by monitoring the `.system_generated/logs/transcript.jsonl` files and submitting local Python subprocess commands.
59
+
60
+ ## Building
61
+
62
+ ```bash
63
+ uv build
64
+ uv tool install .
65
+ ```
66
+
67
+ For full system documentation, please see the `docs/` folder in the repository root.
@@ -0,0 +1,20 @@
1
+ # Antimatter: Antigravity 2.0 Adapter (AG2)
2
+
3
+ This directory contains the Antimatter IPC adapter for the Antigravity 2.0 SDK CLI.
4
+
5
+ ## Architecture
6
+
7
+ This adapter follows the **Independent Adapter Model**. It does NOT contain any complex networking, Cloudflare tunnels, or cryptographic pairing logic. Instead, it acts purely as a "dumb" IPC client that connects to the central Antimatter Gateway.
8
+
9
+ 1. **Connection**: The daemon connects locally to the Gateway via WebSocket at `ws://127.0.0.1:8765`.
10
+ 2. **Registration**: Upon connection, it sends `{"type": "REGISTER_ADAPTER", "name": "ag2"}`.
11
+ 3. **Execution**: When you send a message from the Antimatter Android app targeting AG2, the Gateway securely routes that payload to this adapter. This adapter interfaces with the Antigravity 2.0 SDK by monitoring the `.system_generated/logs/transcript.jsonl` files and submitting local Python subprocess commands.
12
+
13
+ ## Building
14
+
15
+ ```bash
16
+ uv build
17
+ uv tool install .
18
+ ```
19
+
20
+ For full system documentation, please see the `docs/` folder in the repository root.
File without changes
@@ -0,0 +1,456 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import glob
5
+ from pathlib import Path
6
+
7
+
8
+ import asyncio
9
+
10
+ class AgentBridge:
11
+ def __init__(self, websocket, app_data_dir: str, current_convo_id: str):
12
+ self.websocket = websocket
13
+ self.agent = None
14
+ self.app_data_dir = app_data_dir
15
+ self.conversation_id = current_convo_id
16
+ self.step_index = 0
17
+ # BUG-005: Initialize last_position here so poll_transcript never raises AttributeError
18
+ # even if send_transcript was not called first
19
+ self.last_position = 0
20
+ # BUG-006: Stop event for graceful shutdown of poll_transcript
21
+ self._stop_event = asyncio.Event()
22
+ self._watch_task = None
23
+
24
+ async def _send_step(self, case: str, value: str = None, tool: str = None):
25
+ step_payload = {"case": case}
26
+ if value is not None:
27
+ step_payload["value"] = value
28
+ if tool is not None:
29
+ step_payload["tool"] = tool
30
+
31
+ await self.websocket.send(json.dumps({
32
+ "type": "STEP",
33
+ "index": self.step_index,
34
+ "step": step_payload
35
+ }))
36
+ self.step_index += 1
37
+
38
+ async def _send_generating(self):
39
+ await self.websocket.send(json.dumps({
40
+ "type": "GENERATING",
41
+ "conversationId": self.conversation_id
42
+ }))
43
+
44
+ async def _send_response_complete(self):
45
+ await self.websocket.send(json.dumps({
46
+ "type": "RESPONSE_COMPLETE",
47
+ "conversationId": self.conversation_id
48
+ }))
49
+
50
+ async def send_history(self):
51
+ brain_dir = os.path.join(self.app_data_dir, "brain")
52
+ conversations = []
53
+ if os.path.exists(brain_dir):
54
+ import json, re
55
+ for d in os.listdir(brain_dir):
56
+ d_path = os.path.join(brain_dir, d)
57
+ logs_dir = os.path.join(d_path, ".system_generated", "logs")
58
+ if os.path.isdir(logs_dir):
59
+ transcript_path = os.path.join(logs_dir, "transcript.jsonl")
60
+ if os.path.exists(transcript_path):
61
+ timestamp = int(os.path.getmtime(transcript_path) * 1000)
62
+ title = "New Conversation"
63
+ try:
64
+ with open(transcript_path, 'r', encoding='utf-8') as f:
65
+ for line in f:
66
+ try:
67
+ data = json.loads(line)
68
+ if data.get("type") == "USER_INPUT":
69
+ content = data.get("content", "")
70
+ match = re.search(r'<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>', content, re.DOTALL)
71
+ if match:
72
+ title = match.group(1).strip()
73
+ else:
74
+ title = re.sub(r'<[^>]+>', '', content).strip()
75
+ # Truncate title
76
+ if len(title) > 50:
77
+ title = title[:47] + "..."
78
+ break
79
+ except Exception:
80
+ pass
81
+ except Exception:
82
+ pass
83
+
84
+ conversations.append({
85
+ "id": d,
86
+ "timestamp": timestamp,
87
+ "title": title
88
+ })
89
+
90
+ # Sort descending by timestamp
91
+ conversations.sort(key=lambda x: x["timestamp"], reverse=True)
92
+
93
+ if not conversations:
94
+ conversations = [{
95
+ "id": self.conversation_id,
96
+ "timestamp": 0,
97
+ "title": "Current Session"
98
+ }]
99
+
100
+ await self.websocket.send(json.dumps({
101
+ "type": "HISTORY_LIST",
102
+ "conversations": conversations
103
+ }))
104
+
105
+ def _is_boilerplate(self, text: str) -> bool:
106
+ t = text.lower()
107
+ if t.startswith("#") and ("tool" in t or "instruction" in t):
108
+ return True
109
+ keywords = [
110
+ "critical instruction", "tool specificity", "tool usage",
111
+ "tool ecosystem", "tool repertoire", "instruction to avoid cat",
112
+ "eliminating the use of ls", "default to grep_search",
113
+ "actively avoiding using ls", "avoiding cat within bash",
114
+ "before making tool calls", "list related tools",
115
+ "tool list: `", "i must prioritize using the most specific tool"
116
+ ]
117
+ return any(k in t for k in keywords)
118
+
119
+ def _parse_line(self, line: str):
120
+ import json
121
+ import re
122
+ try:
123
+ data = json.loads(line)
124
+ msg_type = data.get("type")
125
+ content = data.get("content", "")
126
+
127
+ steps_to_return = []
128
+
129
+ def add_step(case_type, value):
130
+ steps_to_return.append({
131
+ "index": self.step_index,
132
+ "step": {"case": case_type, "value": value}
133
+ })
134
+ self.step_index += 1
135
+
136
+ if msg_type == "USER_INPUT":
137
+ match = re.search(r'<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>', content, re.DOTALL)
138
+ if match:
139
+ content = match.group(1).strip()
140
+ else:
141
+ content = re.sub(r'<[^>]+>', '', content).strip()
142
+ add_step("userInput", content)
143
+ elif msg_type == "SYSTEM_MESSAGE":
144
+ match = re.search(r'content=(.*?)(?:\n</SYSTEM_MESSAGE>|$)', content, re.DOTALL)
145
+ if match:
146
+ parsed_content = match.group(1).strip()
147
+ if not parsed_content.startswith("Task id") and not parsed_content.startswith("Tool is running"):
148
+ add_step("SYSTEM_MESSAGE", parsed_content)
149
+ elif msg_type == "PLANNER_RESPONSE":
150
+ thinking = data.get("thinking", "")
151
+ if thinking:
152
+ clean_thinking = [
153
+ paragraph for paragraph in thinking.split("\n\n")
154
+ if not self._is_boilerplate(paragraph)
155
+ ]
156
+
157
+ final_thinking = "\n\n".join(clean_thinking).strip()
158
+ if final_thinking:
159
+ stripped_of_punctuation = re.sub(r'[#\s*\-=_]+', '', final_thinking).strip()
160
+ if stripped_of_punctuation:
161
+ add_step("plannerResponse", final_thinking)
162
+ if content:
163
+ add_step("text", content)
164
+ elif msg_type == "SYSTEM_ALERT":
165
+ add_step("ephemeralMessage", content)
166
+ elif msg_type == "MODEL_TEXT":
167
+ add_step("text", content)
168
+ elif msg_type in ["VIEW_FILE", "LIST_DIRECTORY", "CODE_ACTION", "GREP_SEARCH", "READ_URL_CONTENT", "SEARCH_WEB"]:
169
+ pass
170
+ elif msg_type == "ERROR_MESSAGE":
171
+ add_step("errorMessage", content)
172
+
173
+ return steps_to_return
174
+ except Exception:
175
+ return []
176
+
177
+ async def send_transcript(self, convo_id: str):
178
+ transcript_path = os.path.join(self.app_data_dir, "brain", convo_id, ".system_generated", "logs", "transcript.jsonl")
179
+ steps = []
180
+
181
+ self.step_index = 0
182
+ self.last_position = 0
183
+
184
+ if os.path.exists(transcript_path):
185
+ try:
186
+ with open(transcript_path, 'r', encoding='utf-8') as f:
187
+ for line in f:
188
+ parsed_steps = self._parse_line(line)
189
+ if parsed_steps:
190
+ steps.extend(parsed_steps)
191
+ self.last_position = f.tell()
192
+
193
+ except Exception as e:
194
+ print(f"[Gateway] ERROR in reading transcript: {e}", flush=True)
195
+
196
+ await self.websocket.send(json.dumps({
197
+ "type": "SESSION_STATE",
198
+ "conversationId": convo_id,
199
+ "stepCount": len(steps)
200
+ }))
201
+
202
+ await self.websocket.send(json.dumps({
203
+ "type": "STEP_BATCH",
204
+ "conversationId": convo_id,
205
+ "steps": steps
206
+ }))
207
+
208
+ async def poll_transcript(self):
209
+ """Poll the transcript file for new lines, with graceful shutdown and debouncing."""
210
+ import asyncio
211
+ current_opened_convo_id = None
212
+ f = None
213
+
214
+ try:
215
+ while not self._stop_event.is_set():
216
+ if current_opened_convo_id != self.conversation_id:
217
+ if f:
218
+ f.close()
219
+ transcript_path = os.path.join(
220
+ self.app_data_dir, "brain", self.conversation_id,
221
+ ".system_generated", "logs", "transcript.jsonl"
222
+ )
223
+ # Wait for file to exist
224
+ while not os.path.exists(transcript_path):
225
+ if self._stop_event.is_set() or current_opened_convo_id != self.conversation_id:
226
+ break
227
+ await asyncio.sleep(1)
228
+
229
+ if self._stop_event.is_set():
230
+ break
231
+
232
+ if os.path.exists(transcript_path):
233
+ f = open(transcript_path, 'r', encoding='utf-8')
234
+ f.seek(self.last_position)
235
+ current_opened_convo_id = self.conversation_id
236
+
237
+ if f:
238
+ line = f.readline()
239
+ if not line:
240
+ await asyncio.sleep(0.1)
241
+ continue
242
+
243
+ parsed_steps = self._parse_line(line)
244
+ if parsed_steps:
245
+ for s in parsed_steps:
246
+ if s["step"]["case"] in ["userInput", "toolCall"]:
247
+ await self._send_generating()
248
+ elif s["step"]["case"] in ["text", "errorMessage", "ephemeralMessage"]:
249
+ await self._send_response_complete()
250
+
251
+ await self.websocket.send(json.dumps({
252
+ "type": "STEP_BATCH",
253
+ "conversationId": self.conversation_id,
254
+ "steps": parsed_steps
255
+ }))
256
+ else:
257
+ await asyncio.sleep(0.5)
258
+ finally:
259
+ if f:
260
+ f.close()
261
+
262
+ async def watch_brain_dir(self):
263
+ brain_dir = os.path.join(self.app_data_dir, "brain")
264
+ while not self._stop_event.is_set():
265
+ try:
266
+ if os.path.exists(brain_dir):
267
+ subdirs = [os.path.join(brain_dir, d) for d in os.listdir(brain_dir) if os.path.isdir(os.path.join(brain_dir, d))]
268
+ if subdirs:
269
+ newest_dir = max(subdirs, key=os.path.getmtime)
270
+ newest_convo_id = os.path.basename(newest_dir)
271
+ if newest_convo_id != "scratch":
272
+ # Only broadcast history so the sidebar updates, don't force a UI switch
273
+ await self.send_history()
274
+ except Exception:
275
+ pass
276
+ await asyncio.sleep(2)
277
+
278
+ async def send_artifacts(self, convo_id: str):
279
+ artifacts_dir = os.path.join(self.app_data_dir, "brain", convo_id)
280
+ artifacts = []
281
+ if os.path.exists(artifacts_dir):
282
+ for file in os.listdir(artifacts_dir):
283
+ if file.endswith(".md") and file not in ["task.md", "walkthrough.md", "implementation_plan.md"]:
284
+ artifacts.append({
285
+ "name": file,
286
+ "path": os.path.join(artifacts_dir, file),
287
+ "isDir": False
288
+ })
289
+ # Add standard artifacts
290
+ for std in ["implementation_plan.md", "task.md", "walkthrough.md"]:
291
+ if os.path.exists(os.path.join(artifacts_dir, std)):
292
+ artifacts.append({
293
+ "name": std,
294
+ "path": os.path.join(artifacts_dir, std),
295
+ "isDir": False
296
+ })
297
+
298
+ await self.websocket.send(json.dumps({
299
+ "type": "ARTIFACTS_LIST",
300
+ "artifacts": artifacts
301
+ }))
302
+
303
+ async def read_artifact(self, path: str):
304
+ import pathlib
305
+ p = pathlib.Path(path).resolve()
306
+ brain_dir = pathlib.Path(self.app_data_dir) / "brain"
307
+ try:
308
+ p.relative_to(brain_dir.resolve())
309
+ except ValueError:
310
+ return # Path traversal rejected
311
+
312
+ if p.exists():
313
+ try:
314
+ with open(p, 'r', encoding='utf-8') as f:
315
+ content = f.read()
316
+ await self.websocket.send(json.dumps({
317
+ "type": "ARTIFACT_CONTENT",
318
+ "path": str(p),
319
+ "content": content
320
+ }))
321
+ except Exception:
322
+ pass
323
+
324
+ async def send_workspace(self, root_path=None):
325
+ if root_path is None:
326
+ root_path = os.getcwd()
327
+
328
+ IGNORED = {
329
+ 'node_modules', '.git', 'dist', 'build', 'out', '.gradle',
330
+ '__pycache__', '.venv', 'venv', '.idea', '.DS_Store', '.kotlin',
331
+ }
332
+
333
+ # Performance: Build the file tree in a thread pool so the synchronous
334
+ # os.listdir/os.path.isdir calls don't block the asyncio event loop.
335
+ def build_tree(path: str, depth: int = 0) -> list:
336
+ if depth > 2:
337
+ return []
338
+ nodes = []
339
+ try:
340
+ for item in sorted(os.listdir(path)):
341
+ if item.startswith('.') or item in IGNORED:
342
+ continue
343
+ full_path = os.path.join(path, item)
344
+ is_dir = os.path.isdir(full_path)
345
+ node = {
346
+ "name": item,
347
+ "path": full_path,
348
+ "isDir": is_dir
349
+ }
350
+ if is_dir and depth < 2:
351
+ node["children"] = build_tree(full_path, depth + 1)
352
+ nodes.append(node)
353
+ except PermissionError:
354
+ pass
355
+ except Exception:
356
+ pass
357
+ # Directories first, then files, both alphabetical
358
+ return sorted(nodes, key=lambda x: (not x["isDir"], x["name"]))
359
+
360
+ # Run synchronous tree scan off the event loop
361
+ tree = await asyncio.to_thread(build_tree, root_path)
362
+ await self.websocket.send(json.dumps({
363
+ "type": "FILE_TREE",
364
+ "tree": tree
365
+ }))
366
+
367
+ async def read_file(self, path: str):
368
+ import pathlib
369
+ workspace = pathlib.Path(os.getcwd()).resolve()
370
+ p = pathlib.Path(path)
371
+ if not p.is_absolute():
372
+ p = workspace / p
373
+ p = p.resolve()
374
+
375
+ try:
376
+ p.relative_to(workspace)
377
+ except ValueError:
378
+ await self.websocket.send(json.dumps({
379
+ "type": "ERROR",
380
+ "message": "Path traversal rejected"
381
+ }))
382
+ return
383
+
384
+ try:
385
+ with open(p, 'r', encoding='utf-8') as f:
386
+ content = f.read()
387
+ await self.websocket.send(json.dumps({
388
+ "type": "FILE_CONTENT",
389
+ "path": str(p),
390
+ "content": content,
391
+ "language": "markdown" if str(p).endswith(".md") else "text"
392
+ }))
393
+ except Exception as e:
394
+ await self.websocket.send(json.dumps({
395
+ "type": "ERROR",
396
+ "message": f"Failed to read file: {e}"
397
+ }))
398
+
399
+ async def process_message(self, text: str, images: list = None):
400
+ if images is None:
401
+ images = []
402
+
403
+ # Process images
404
+ if images:
405
+ import base64
406
+ import time
407
+ scratch_dir = os.path.join(self.app_data_dir, "brain", self.conversation_id, "scratch")
408
+ os.makedirs(scratch_dir, exist_ok=True)
409
+
410
+ for i, b64_str in enumerate(images):
411
+ try:
412
+ if "," in b64_str:
413
+ b64_str = b64_str.split(",")[1]
414
+
415
+ img_data = base64.b64decode(b64_str)
416
+ timestamp = int(time.time() * 1000)
417
+ filename = f"upload_{timestamp}_{i}.jpg"
418
+ filepath = os.path.join(scratch_dir, filename)
419
+
420
+ with open(filepath, "wb") as f:
421
+ f.write(img_data)
422
+
423
+ text += f"\\n\\n![Image](file://{filepath})"
424
+ except Exception as e:
425
+ print(f"Failed to decode image {i}: {e}")
426
+
427
+ # We DO NOT send userInput directly anymore because Agent.chat() or agentapi will append it to the file,
428
+ # and poll_transcript will pick it up and broadcast it properly, avoiding duplicates.
429
+ await self._send_generating()
430
+
431
+ try:
432
+ import asyncio
433
+ agentapi_path = os.path.expanduser("~/.gemini/antigravity/bin/agentapi")
434
+
435
+ proc = await asyncio.create_subprocess_exec(
436
+ agentapi_path, "send-message", str(self.conversation_id), str(text),
437
+ stdout=asyncio.subprocess.PIPE,
438
+ stderr=asyncio.subprocess.PIPE
439
+ )
440
+ stdout, stderr = await proc.communicate()
441
+
442
+ if proc.returncode != 0:
443
+ if b"ANTIGRAVITY_LS_ADDRESS is not set" in stderr:
444
+ logger.error("FATAL: Adapter MUST be started from inside the Antigravity IDE integrated terminal!")
445
+ await self._send_step("errorMessage", f"Failed to inject message into IDE: {stderr.decode()}")
446
+ else:
447
+ await self._send_step("ephemeralMessage", "Message queued to IDE Agent.")
448
+ except Exception as e:
449
+ await self._send_step("errorMessage", f"Agent Error: {str(e)}")
450
+ finally:
451
+ await self._send_response_complete()
452
+
453
+
454
+ async def cleanup(self):
455
+ # BUG-006: Signal poll_transcript to stop gracefully
456
+ self._stop_event.set()
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "antimatter-ag2",
3
+ "version": "1.0.0",
4
+ "description": "Seamless integration with the Antimatter Android App."
5
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: antimatter-ag2
3
+ description: "Starts the Antimatter AG2 adapter to connect the IDE to the Gateway. Use this skill when the user asks to start, manage, or connect their AG2 adapter."
4
+ ---
5
+ # Antimatter AG2 Adapter Skill
6
+
7
+ This skill teaches the agent how to launch and manage the Antimatter AG2 Adapter natively.
8
+
9
+ ## How to use this skill
10
+
11
+ 1. When the user asks to start the adapter, execute the following command (using the `run_command` tool):
12
+
13
+ ```bash
14
+ # Start the Antigravity 2.0 adapter (it will automatically run in the background)
15
+ antimatter-ag2 start
16
+ ```
17
+ *(If developing locally, you can run `uv run antimatter-ag2 start` from its directory)*
18
+
19
+ 2. The daemon runs indefinitely and handles all IDE communication seamlessly via the Google Antigravity SDK. You do not need to intervene in the process once it has started.
20
+
21
+ ## Stopping or Restarting the Adapter
22
+
23
+ If the user asks to "stop", "terminate", or "kill" the Antimatter Adapter, simply run:
24
+ ```bash
25
+ antimatter-ag2 stop
26
+ ```
27
+
28
+ If the user asks to "restart" the adapter, run `stop` followed by `start`.
@@ -0,0 +1,131 @@
1
+ import os
2
+ import sys
3
+ import shutil
4
+ import argparse
5
+ from pathlib import Path
6
+ from antimatter_ag2 import server
7
+
8
+ def init_plugin():
9
+ """Initializes the plugin by copying assets into the Antigravity IDE plugins directory."""
10
+ print("Initializing Antimatter AG2 plugin...")
11
+
12
+ home_dir = Path.home()
13
+ plugin_dir = home_dir / ".gemini" / "config" / "plugins" / "antimatter-ag2"
14
+
15
+ # Define source assets path
16
+ assets_dir = Path(__file__).parent / "assets"
17
+
18
+ if not assets_dir.exists():
19
+ print(f"Error: Could not find assets directory at {assets_dir}", file=sys.stderr)
20
+ sys.exit(1)
21
+
22
+ try:
23
+ # Create target directories
24
+ print(f"Creating directory: {plugin_dir}")
25
+ plugin_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ # 1. Copy plugin.json
28
+ print("Copying plugin.json...")
29
+ shutil.copy2(assets_dir / "plugin.json", plugin_dir / "plugin.json")
30
+
31
+ # 2. Create skills directory and copy SKILL.md
32
+ skill_dir = plugin_dir / "skills" / "antimatter-ag2"
33
+ print(f"Creating directory: {skill_dir}")
34
+ skill_dir.mkdir(parents=True, exist_ok=True)
35
+
36
+ print("Copying SKILL.md...")
37
+ shutil.copy2(assets_dir / "skills" / "antimatter-ag2" / "SKILL.md", skill_dir / "SKILL.md")
38
+
39
+ print("\n✅ Successfully initialized Antimatter AG2 Adapter!")
40
+ print("You can now open Antigravity 2.0 and say: 'Start my Antimatter adapter'")
41
+
42
+ except Exception as e:
43
+ print(f"Failed to initialize plugin: {e}", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+ def start_server():
47
+ """Starts the WebSocket bridge adapter client in the background."""
48
+ print("Starting Antimatter AG2 Adapter in background...")
49
+
50
+ app_data = Path.home() / ".gemini" / "antigravity"
51
+ app_data.mkdir(parents=True, exist_ok=True)
52
+ pid_file = app_data / ".ag2.pid"
53
+
54
+ if pid_file.exists():
55
+ try:
56
+ pid = int(pid_file.read_text())
57
+ os.kill(pid, 0)
58
+ print("Adapter is already running.")
59
+ return
60
+ except (ValueError, OSError):
61
+ pid_file.unlink(missing_ok=True)
62
+
63
+ import subprocess
64
+
65
+ process = subprocess.Popen(
66
+ [sys.executable, "-m", "antimatter_ag2.cli", "run_server"],
67
+ stdout=subprocess.DEVNULL,
68
+ stderr=subprocess.DEVNULL,
69
+ start_new_session=True
70
+ )
71
+
72
+ pid_file.write_text(str(process.pid))
73
+ print(f"✅ Adapter started successfully (PID: {process.pid}).")
74
+
75
+ def stop_server():
76
+ """Stops the background adapter."""
77
+ pid_file = Path.home() / ".gemini" / "antigravity" / ".ag2.pid"
78
+ if not pid_file.exists():
79
+ print("Adapter is not running.")
80
+ return
81
+
82
+ try:
83
+ pid = int(pid_file.read_text())
84
+ import signal
85
+ os.kill(pid, signal.SIGTERM)
86
+ print("Adapter stopped successfully.")
87
+ except Exception as e:
88
+ print(f"Failed to stop adapter or it was not running: {e}")
89
+ finally:
90
+ pid_file.unlink(missing_ok=True)
91
+
92
+ def run_server():
93
+ """Actually runs the server loop (internal use)."""
94
+ try:
95
+ import asyncio
96
+ asyncio.run(server.main())
97
+ except KeyboardInterrupt:
98
+ pass
99
+
100
+ def main():
101
+ parser = argparse.ArgumentParser(description="Antimatter AG2 CLI")
102
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
103
+
104
+ # Init command
105
+ init_parser = subparsers.add_parser("init", help="Initialize the plugin in the Antigravity SDK directory")
106
+
107
+ # Start command
108
+ start_parser = subparsers.add_parser("start", help="Start the WebSocket daemon in the background")
109
+
110
+ # Stop command
111
+ stop_parser = subparsers.add_parser("stop", help="Stop the background daemon")
112
+
113
+ # Run server command (internal)
114
+ run_parser = subparsers.add_parser("run_server", help=argparse.SUPPRESS)
115
+
116
+ args = parser.parse_args()
117
+
118
+ if args.command == "init":
119
+ init_plugin()
120
+ elif args.command == "start":
121
+ start_server()
122
+ elif args.command == "stop":
123
+ stop_server()
124
+ elif args.command == "run_server":
125
+ run_server()
126
+ else:
127
+ parser.print_help()
128
+ sys.exit(1)
129
+
130
+ if __name__ == "__main__":
131
+ main()
@@ -0,0 +1,135 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import websockets
5
+ import os
6
+ from pathlib import Path
7
+ from .agent_bridge import AgentBridge
8
+
9
+ logging.basicConfig(level=logging.WARNING)
10
+ logger = logging.getLogger(__name__)
11
+
12
+ APP_DATA_DIR = Path(os.environ.get("AGY_APP_DATA_DIR", os.path.expanduser("~/.gemini/antigravity")))
13
+
14
+ def get_latest_conversation_id(app_data_dir: Path) -> str:
15
+ brain_dir = app_data_dir / "brain"
16
+ if not brain_dir.exists():
17
+ return "default"
18
+
19
+ conv_dirs = [d.name for d in brain_dir.iterdir() if d.is_dir() and (brain_dir / d / ".system_generated").exists()]
20
+ if not conv_dirs:
21
+ return "default"
22
+
23
+ conv_dirs.sort(key=lambda d: (brain_dir / d).stat().st_mtime, reverse=True)
24
+ return conv_dirs[0]
25
+
26
+ def get_default_workspace() -> str:
27
+ if "AGY_WORKSPACE_DIR" in os.environ:
28
+ return os.environ["AGY_WORKSPACE_DIR"]
29
+ try:
30
+ current = Path(__file__).resolve().parent
31
+ while current != current.parent:
32
+ if (current / ".git").exists():
33
+ return str(current)
34
+ current = current.parent
35
+ except Exception:
36
+ pass
37
+ return os.getcwd()
38
+
39
+ async def main():
40
+ uri = "ws://127.0.0.1:8765"
41
+ logger.info(f"[AG2 Adapter] Connecting to Gateway at {uri}...")
42
+
43
+ while True:
44
+ try:
45
+ async with websockets.connect(uri) as websocket:
46
+ logger.info("[AG2 Adapter] Connected to Gateway IPC.")
47
+
48
+ import uuid
49
+ import os
50
+ id_file = APP_DATA_DIR / ".agent_id"
51
+ if id_file.exists():
52
+ agent_id = id_file.read_text().strip()
53
+ else:
54
+ agent_id = str(uuid.uuid4())
55
+ id_file.write_text(agent_id)
56
+
57
+ # Register
58
+ await websocket.send(json.dumps({
59
+ "type": "REGISTER_ADAPTER",
60
+ "id": agent_id,
61
+ "name": "ag2",
62
+ "workspaceRoot": get_default_workspace()
63
+ }))
64
+
65
+ convo_id = os.environ.get("AGY_CONVERSATION_ID") or get_latest_conversation_id(APP_DATA_DIR)
66
+ bridge = AgentBridge(websocket, str(APP_DATA_DIR), convo_id)
67
+ bridge._watch_task = asyncio.create_task(bridge.watch_brain_dir())
68
+ bridge._poll_task = asyncio.create_task(bridge.poll_transcript())
69
+
70
+ try:
71
+ async for message in websocket:
72
+ try:
73
+ data = json.loads(message)
74
+ msg_type = data.get("type")
75
+
76
+ if msg_type == "SUBSCRIBE_CONVERSATION":
77
+ cid = data.get("conversationId")
78
+ if cid:
79
+ bridge.conversation_id = cid
80
+ if bridge.conversation_id:
81
+ await bridge.send_transcript(bridge.conversation_id)
82
+ await bridge.send_artifacts(bridge.conversation_id)
83
+ await bridge.send_workspace(get_default_workspace())
84
+
85
+ elif msg_type == "NEW_CONVERSATION":
86
+ # ag2 does not support new conversations, but we shouldn't throw an error
87
+ # because the Android app calls this automatically when switching agents.
88
+ # Instead, just send the current session state.
89
+ if bridge.conversation_id:
90
+ await bridge.send_transcript(bridge.conversation_id)
91
+
92
+ elif msg_type == "GET_HISTORY":
93
+ await bridge.send_history()
94
+
95
+ elif msg_type == "GET_ARTIFACTS":
96
+ cid = data.get("conversationId", bridge.conversation_id)
97
+ if cid:
98
+ await bridge.send_artifacts(cid)
99
+
100
+ elif msg_type == "READ_ARTIFACT":
101
+ path = data.get("path")
102
+ if path:
103
+ await bridge.read_artifact(path)
104
+
105
+ elif msg_type == "GET_FILES":
106
+ await bridge.send_workspace(get_default_workspace())
107
+
108
+ elif msg_type == "READ_FILE":
109
+ path = data.get("path")
110
+ if path:
111
+ await bridge.read_file(path)
112
+
113
+ elif msg_type == "SEND_MESSAGE":
114
+ logger.info(f"[AG2 Adapter] Received SEND_MESSAGE: {data.get('text', '')[:50]}...")
115
+ text = data.get("text", "")
116
+ images = data.get("images", [])
117
+ asyncio.create_task(bridge.process_message(text, images))
118
+
119
+ elif msg_type == "PING":
120
+ await websocket.send(json.dumps({"type": "PONG"}))
121
+
122
+ except json.JSONDecodeError:
123
+ pass
124
+ finally:
125
+ await bridge.cleanup()
126
+
127
+ except Exception as e:
128
+ logger.warning(f"[AG2 Adapter] Connection error: {e}. Reconnecting in 3s...")
129
+ await asyncio.sleep(3)
130
+
131
+ if __name__ == "__main__":
132
+ try:
133
+ asyncio.run(main())
134
+ except KeyboardInterrupt:
135
+ print("\nAdapter shut down gracefully.")
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "antimatter-ag2"
7
+ version = "1.0.0"
8
+ description = "Antimatter AG2 Adapter — companion daemon for Google AntiGravity IDE"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [
12
+ { name = "Saif Mukhtar", email = "saifmukhtar@saifmukhtar.dev" }
13
+ ]
14
+ requires-python = ">=3.11"
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Software Development",
19
+ "Topic :: Communications",
20
+ "Intended Audience :: Developers",
21
+ "Environment :: Console",
22
+ ]
23
+ keywords = ["antigravity", "ai", "ide", "mobile", "bridge", "websocket", "android"]
24
+ dependencies = [
25
+ "websockets==15.0.1",
26
+ "google-antigravity==0.1.2",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ test = [
31
+ "pytest>=7.0.0",
32
+ "pytest-asyncio>=0.23.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://saifmukhtar.dev"
37
+ Repository = "https://github.com/saifmukhtar/antimatter"
38
+ Issues = "https://github.com/saifmukhtar/antimatter/issues"
39
+ Documentation = "https://saifmukhtar.dev/docs/antimatter"
40
+ Changelog = "https://github.com/saifmukhtar/antimatter/releases"
41
+
42
+ [project.scripts]
43
+ antimatter-ag2 = "antimatter_ag2.cli:main"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["antimatter_ag2"]
47
+
48
+ [tool.hatch.build.targets.sdist]
49
+ include = [
50
+ "antimatter_ag2",
51
+ ]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_mode = "auto"
55
+ testpaths = ["tests"]