antigravity-wakeup 0.1.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.
- antigravity_wakeup-0.1.0/LICENSE +21 -0
- antigravity_wakeup-0.1.0/PKG-INFO +82 -0
- antigravity_wakeup-0.1.0/README.md +52 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/__init__.py +6 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/agentapi.py +57 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/cli.py +27 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/command.py +48 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/control.py +48 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/endpoint.py +72 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/hook.py +76 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/paths.py +8 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/pinning.py +19 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/poker.py +111 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/prime.py +53 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup/shell.py +97 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup.egg-info/PKG-INFO +82 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup.egg-info/SOURCES.txt +22 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup.egg-info/dependency_links.txt +1 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup.egg-info/entry_points.txt +2 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup.egg-info/requires.txt +5 -0
- antigravity_wakeup-0.1.0/antigravity_wakeup.egg-info/top_level.txt +1 -0
- antigravity_wakeup-0.1.0/pyproject.toml +47 -0
- antigravity_wakeup-0.1.0/setup.cfg +4 -0
- antigravity_wakeup-0.1.0/tests/test_antigravity_wakeup.py +423 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladimir Berezhnoy <non7top@gmail.com>
|
|
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,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: antigravity-wakeup
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Wake an idle Antigravity (agy) conversation from another process via agy agentapi send-message.
|
|
5
|
+
Author: non7top
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/non7top/antigravity-wakeup
|
|
8
|
+
Project-URL: Source, https://github.com/non7top/antigravity-wakeup
|
|
9
|
+
Project-URL: Issues, https://github.com/non7top/antigravity-wakeup/issues
|
|
10
|
+
Keywords: antigravity,agy,wakeup,push,agentapi
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: psutil>=5.9.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
28
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# antigravity-wakeup
|
|
32
|
+
|
|
33
|
+
Wake an idle Antigravity CLI (`agy`) conversation from another process.
|
|
34
|
+
|
|
35
|
+
An MCP server cannot start a turn in `agy`. The only way is `agy agentapi send-message`, which needs `ANTIGRAVITY_LS_ADDRESS` and `ANTIGRAVITY_CSRF_TOKEN`. `agy` puts those (and `ANTIGRAVITY_CONVERSATION_ID`) only into its agent's shell tool, never into MCP servers or hook processes. This library works around that with a one-time "prime" step run from the agent's own shell.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
1. A host process (for example an MCP server running under `agy`) creates a `Prime`, builds a poker with `prime.new_poker()` and serves its control socket (`poker.serve_control_socket(path)`, a `0600` Unix socket).
|
|
40
|
+
2. The agent runs the command from `prime.command()` in its shell tool. That runs `antigravity-wakeup --prime`, which reads the `ANTIGRAVITY_*` environment, finds the host process under the same `agy` process and sends the endpoint to its control socket.
|
|
41
|
+
3. The host calls `poker.notify(sender)` whenever something arrives. Notifications are debounced, then the poker runs `agy agentapi send-message` with text from your `wake_text` callback. A send counts as delivered only when agy's reply names the conversation (`response.sendMessage.recipientId`).
|
|
42
|
+
4. A marker file records which host is primed for a conversation. The `PreInvocation` hook (`antigravity-wakeup --prime-hook`) reads it and, for an unprimed conversation, injects an `ephemeralMessage` telling the agent to run the prime command. After a failed attempt the hook waits 10 minutes before asking again.
|
|
43
|
+
|
|
44
|
+
The token is held in memory only and reaches the `agy` child through its environment, never argv, logs or disk. Only loopback addresses are accepted. Optional `pin_agy_process=True` also checks that the primed `agy` process (pid plus start time) is still the same one before each send.
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from antigravity_wakeup.prime import Prime
|
|
50
|
+
|
|
51
|
+
prime = Prime(
|
|
52
|
+
script_name="my-tool", # console script that runs --prime / --prime-hook
|
|
53
|
+
module="my_tool.cli", # fallback for `python -m` when the script is not found
|
|
54
|
+
notice="...", # text injected by the hook for an unprimed conversation
|
|
55
|
+
wake_text=lambda senders: "...", # text sent on each wake; must not carry peer-written content
|
|
56
|
+
)
|
|
57
|
+
poker = prime.new_poker()
|
|
58
|
+
# in the host's event loop: asyncio.create_task(poker.serve_control_socket(path))
|
|
59
|
+
# when something arrives: poker.notify(sender)
|
|
60
|
+
# to tell the agent what to run: prime.command()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The `antigravity-wakeup` command exposes `--prime` and `--prime-hook` for a host that has no CLI of its own; `antigravity_wakeup.cli.main(prime)` takes a configured `Prime`.
|
|
64
|
+
|
|
65
|
+
## Current limits
|
|
66
|
+
|
|
67
|
+
Several defaults still come from the project this was split out of: marker and stable-link directories under `~/.claude/chatter-*`, and host discovery through Claude Code's `~/.claude/sessions` files (`messagingSocketPath`). Making those pluggable is open work.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
make build
|
|
73
|
+
make lint
|
|
74
|
+
make test
|
|
75
|
+
make package
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Everything runs in a throwaway container with its own `$HOME`.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# antigravity-wakeup
|
|
2
|
+
|
|
3
|
+
Wake an idle Antigravity CLI (`agy`) conversation from another process.
|
|
4
|
+
|
|
5
|
+
An MCP server cannot start a turn in `agy`. The only way is `agy agentapi send-message`, which needs `ANTIGRAVITY_LS_ADDRESS` and `ANTIGRAVITY_CSRF_TOKEN`. `agy` puts those (and `ANTIGRAVITY_CONVERSATION_ID`) only into its agent's shell tool, never into MCP servers or hook processes. This library works around that with a one-time "prime" step run from the agent's own shell.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
1. A host process (for example an MCP server running under `agy`) creates a `Prime`, builds a poker with `prime.new_poker()` and serves its control socket (`poker.serve_control_socket(path)`, a `0600` Unix socket).
|
|
10
|
+
2. The agent runs the command from `prime.command()` in its shell tool. That runs `antigravity-wakeup --prime`, which reads the `ANTIGRAVITY_*` environment, finds the host process under the same `agy` process and sends the endpoint to its control socket.
|
|
11
|
+
3. The host calls `poker.notify(sender)` whenever something arrives. Notifications are debounced, then the poker runs `agy agentapi send-message` with text from your `wake_text` callback. A send counts as delivered only when agy's reply names the conversation (`response.sendMessage.recipientId`).
|
|
12
|
+
4. A marker file records which host is primed for a conversation. The `PreInvocation` hook (`antigravity-wakeup --prime-hook`) reads it and, for an unprimed conversation, injects an `ephemeralMessage` telling the agent to run the prime command. After a failed attempt the hook waits 10 minutes before asking again.
|
|
13
|
+
|
|
14
|
+
The token is held in memory only and reaches the `agy` child through its environment, never argv, logs or disk. Only loopback addresses are accepted. Optional `pin_agy_process=True` also checks that the primed `agy` process (pid plus start time) is still the same one before each send.
|
|
15
|
+
|
|
16
|
+
## Use
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from antigravity_wakeup.prime import Prime
|
|
20
|
+
|
|
21
|
+
prime = Prime(
|
|
22
|
+
script_name="my-tool", # console script that runs --prime / --prime-hook
|
|
23
|
+
module="my_tool.cli", # fallback for `python -m` when the script is not found
|
|
24
|
+
notice="...", # text injected by the hook for an unprimed conversation
|
|
25
|
+
wake_text=lambda senders: "...", # text sent on each wake; must not carry peer-written content
|
|
26
|
+
)
|
|
27
|
+
poker = prime.new_poker()
|
|
28
|
+
# in the host's event loop: asyncio.create_task(poker.serve_control_socket(path))
|
|
29
|
+
# when something arrives: poker.notify(sender)
|
|
30
|
+
# to tell the agent what to run: prime.command()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `antigravity-wakeup` command exposes `--prime` and `--prime-hook` for a host that has no CLI of its own; `antigravity_wakeup.cli.main(prime)` takes a configured `Prime`.
|
|
34
|
+
|
|
35
|
+
## Current limits
|
|
36
|
+
|
|
37
|
+
Several defaults still come from the project this was split out of: marker and stable-link directories under `~/.claude/chatter-*`, and host discovery through Claude Code's `~/.claude/sessions` files (`messagingSocketPath`). Making those pluggable is open work.
|
|
38
|
+
|
|
39
|
+
## Development
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
make build
|
|
43
|
+
make lint
|
|
44
|
+
make test
|
|
45
|
+
make package
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Everything runs in a throwaway container with its own `$HOME`.
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The one call made into agy: `agy agentapi send-message`."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .endpoint import Endpoint
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("antigravity_wakeup")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class SendResult:
|
|
16
|
+
ok: bool
|
|
17
|
+
error: Optional[str] = None
|
|
18
|
+
endpoint_gone: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AgentApi:
|
|
22
|
+
def __init__(self, timeout_seconds: float = 20.0, title: str = "antigravity-wakeup"):
|
|
23
|
+
self._timeout = timeout_seconds
|
|
24
|
+
self._title = title
|
|
25
|
+
|
|
26
|
+
async def send_message(self, endpoint: Endpoint, text: str) -> SendResult:
|
|
27
|
+
# The token goes through the child's environment only, never argv or logs.
|
|
28
|
+
env = dict(os.environ)
|
|
29
|
+
env["ANTIGRAVITY_LS_ADDRESS"] = endpoint.ls_address
|
|
30
|
+
env["ANTIGRAVITY_CSRF_TOKEN"] = endpoint.csrf_token
|
|
31
|
+
try:
|
|
32
|
+
proc = await asyncio.create_subprocess_exec(
|
|
33
|
+
endpoint.agy_path, "agentapi", "send-message", f"--title={self._title}",
|
|
34
|
+
endpoint.conversation_id, text,
|
|
35
|
+
env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
36
|
+
)
|
|
37
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self._timeout)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return SendResult(False, f"agentapi invocation failed: {e}")
|
|
40
|
+
return self._classify(stdout, proc.returncode, endpoint.conversation_id)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _classify(stdout: bytes, returncode: Optional[int], conversation_id: str) -> SendResult:
|
|
44
|
+
# agentapi exits 0 even on failure, so the JSON is the only reliable signal.
|
|
45
|
+
try:
|
|
46
|
+
reply = json.loads(stdout.decode("utf-8"))
|
|
47
|
+
except ValueError:
|
|
48
|
+
reply = None
|
|
49
|
+
if not isinstance(reply, dict):
|
|
50
|
+
return SendResult(False, f"unparseable agentapi output (exit {returncode})")
|
|
51
|
+
if reply.get("error"):
|
|
52
|
+
error = str(reply["error"])[:300]
|
|
53
|
+
return SendResult(False, error, endpoint_gone="Unavailable" in error)
|
|
54
|
+
sent = (reply.get("response") or {}).get("sendMessage")
|
|
55
|
+
if not isinstance(sent, dict) or sent.get("recipientId") != conversation_id:
|
|
56
|
+
return SendResult(False, "agy did not confirm delivery to the conversation")
|
|
57
|
+
return SendResult(True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""CLI for the two modes an agy session needs: priming from its shell, and the PreInvocation hook."""
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from .prime import Prime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(prime: Prime = None):
|
|
10
|
+
prime = prime or Prime()
|
|
11
|
+
parser = argparse.ArgumentParser(description="Wake an idle Antigravity conversation")
|
|
12
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
13
|
+
group.add_argument("--prime", action="store_true", help="From an agy shell tool: hand this agy's push endpoint to its host process")
|
|
14
|
+
group.add_argument("--prime-hook", action="store_true", help="PreInvocation hook: reads hook JSON on stdin and prints the JSON that makes the session prime push")
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
|
|
17
|
+
if args.prime_hook:
|
|
18
|
+
print(prime.run_hook(sys.stdin.read()))
|
|
19
|
+
sys.exit(0)
|
|
20
|
+
|
|
21
|
+
result = prime.prime_from_shell()
|
|
22
|
+
print(json.dumps(result, indent=2))
|
|
23
|
+
sys.exit(0 if result.get("ok") else 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The shell command an agent runs to prime push."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from . import paths
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PrimeCommand:
|
|
12
|
+
def __init__(self, script_name: str = "antigravity-wakeup", module: str = "antigravity_wakeup.cli", stable_link: Optional[str] = None):
|
|
13
|
+
self._script_name = script_name
|
|
14
|
+
self._module = module
|
|
15
|
+
self._stable_link = stable_link or os.path.join(paths.STABLE_LINK_DIR, script_name)
|
|
16
|
+
|
|
17
|
+
def build(self) -> str:
|
|
18
|
+
"""The shortest shell command that runs this install's own `--prime`."""
|
|
19
|
+
bin_dir = os.path.dirname(sys.executable)
|
|
20
|
+
script = os.path.join(bin_dir, self._script_name)
|
|
21
|
+
if not os.path.exists(script):
|
|
22
|
+
return f"{shlex.quote(sys.executable)} -m {self._module} --prime"
|
|
23
|
+
# `uv run` puts the project venv's bin on this process's PATH only; the
|
|
24
|
+
# agent's shell won't have it, so don't count that directory as "on PATH".
|
|
25
|
+
other_dirs = [
|
|
26
|
+
d for d in os.environ.get("PATH", "").split(os.pathsep)
|
|
27
|
+
if d and os.path.realpath(d) != os.path.realpath(bin_dir)
|
|
28
|
+
]
|
|
29
|
+
on_path = shutil.which(self._script_name, path=os.pathsep.join(other_dirs))
|
|
30
|
+
if on_path and os.path.realpath(on_path) == os.path.realpath(script):
|
|
31
|
+
return f"{self._script_name} --prime"
|
|
32
|
+
# A uvx cache path changes on every rebuild, which defeats a permission
|
|
33
|
+
# allow-list entry for the command; this link keeps the command stable.
|
|
34
|
+
try:
|
|
35
|
+
self._refresh_stable_link(script)
|
|
36
|
+
return f"{shlex.quote(self._stable_link)} --prime"
|
|
37
|
+
except OSError:
|
|
38
|
+
return f"{shlex.quote(script)} --prime"
|
|
39
|
+
|
|
40
|
+
def _refresh_stable_link(self, script: str):
|
|
41
|
+
os.makedirs(os.path.dirname(self._stable_link), exist_ok=True)
|
|
42
|
+
tmp_path = f"{self._stable_link}.{os.getpid()}"
|
|
43
|
+
try:
|
|
44
|
+
os.remove(tmp_path)
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
pass
|
|
47
|
+
os.symlink(script, tmp_path)
|
|
48
|
+
os.replace(tmp_path, self._stable_link)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The 0600 Unix control socket a primed host listens on for prime and status requests."""
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, Dict
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("antigravity_wakeup")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def control_socket_path(bridge_socket_path: str) -> str:
|
|
12
|
+
return bridge_socket_path + ".ctl"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ControlServer:
|
|
16
|
+
"""One JSON request line in, one JSON reply line out; what the request means is the handler's business."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, handler: Callable[[Dict[str, Any]], Dict[str, Any]]):
|
|
19
|
+
self._handler = handler
|
|
20
|
+
|
|
21
|
+
async def serve(self, path: str):
|
|
22
|
+
if os.path.exists(path):
|
|
23
|
+
os.remove(path)
|
|
24
|
+
server = await asyncio.start_unix_server(self._handle, path)
|
|
25
|
+
os.chmod(path, 0o600)
|
|
26
|
+
logger.info(f"Push control socket operational on: {path}")
|
|
27
|
+
async with server:
|
|
28
|
+
await server.serve_forever()
|
|
29
|
+
|
|
30
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
31
|
+
try:
|
|
32
|
+
line = await asyncio.wait_for(reader.readline(), timeout=5.0)
|
|
33
|
+
try:
|
|
34
|
+
request = json.loads(line.decode("utf-8"))
|
|
35
|
+
except ValueError:
|
|
36
|
+
reply = {"ok": False, "error": "invalid JSON"}
|
|
37
|
+
else:
|
|
38
|
+
reply = self._handler(request) if isinstance(request, dict) else {"ok": False, "error": "invalid JSON"}
|
|
39
|
+
writer.write((json.dumps(reply) + "\n").encode("utf-8"))
|
|
40
|
+
await writer.drain()
|
|
41
|
+
except Exception as e:
|
|
42
|
+
logger.error(f"Push control request failed: {e}")
|
|
43
|
+
finally:
|
|
44
|
+
try:
|
|
45
|
+
writer.close()
|
|
46
|
+
await writer.wait_closed()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The agy conversation to wake: its endpoint credentials and the marker that records it is primed."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from . import paths
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("antigravity_wakeup")
|
|
12
|
+
|
|
13
|
+
# The token is handed to an agy child that connects to this address, so a
|
|
14
|
+
# non-loopback address would send it somewhere else.
|
|
15
|
+
_LOOPBACK_ADDRESS = re.compile(r"^(localhost|127\.0\.0\.1|\[::1\]):\d{1,5}$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidEndpoint(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Endpoint:
|
|
24
|
+
conversation_id: str
|
|
25
|
+
ls_address: str
|
|
26
|
+
csrf_token: str
|
|
27
|
+
agy_path: str
|
|
28
|
+
agy_pid: Optional[int] = None
|
|
29
|
+
agy_started: Optional[float] = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_request(cls, req: Dict[str, Any], require_agy_process: bool = False) -> "Endpoint":
|
|
33
|
+
conv, address, token, agy_path = (req.get(k) for k in ("conversation_id", "ls_address", "csrf_token", "agy_path"))
|
|
34
|
+
if not all(isinstance(v, str) and v for v in (conv, address, token, agy_path)):
|
|
35
|
+
raise InvalidEndpoint("conversation_id, ls_address, csrf_token and agy_path are required strings")
|
|
36
|
+
if not paths.CONVERSATION_ID.match(conv):
|
|
37
|
+
raise InvalidEndpoint("conversation_id has an unexpected format")
|
|
38
|
+
if not _LOOPBACK_ADDRESS.match(address):
|
|
39
|
+
raise InvalidEndpoint("ls_address must be a loopback host:port")
|
|
40
|
+
if not (os.path.isabs(agy_path) and os.access(agy_path, os.X_OK)):
|
|
41
|
+
raise InvalidEndpoint("agy_path must be an absolute path to an executable")
|
|
42
|
+
|
|
43
|
+
pid, started = req.get("agy_pid"), req.get("agy_started")
|
|
44
|
+
has_process = (
|
|
45
|
+
isinstance(pid, int) and not isinstance(pid, bool) and pid > 0
|
|
46
|
+
and isinstance(started, (int, float)) and not isinstance(started, bool)
|
|
47
|
+
)
|
|
48
|
+
if require_agy_process and not has_process:
|
|
49
|
+
raise InvalidEndpoint("agy_pid and agy_started are required when agy process pinning is on")
|
|
50
|
+
return cls(conv, address, token, agy_path, pid if has_process else None, float(started) if has_process else None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PrimedMarker:
|
|
54
|
+
"""Lets the prime hook cheaply tell "already primed" without ever holding the token."""
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def path(conversation_id: str) -> str:
|
|
58
|
+
return os.path.join(paths.PRIMED_DIR, f"{conversation_id}.json")
|
|
59
|
+
|
|
60
|
+
def write(self, conversation_id: str):
|
|
61
|
+
try:
|
|
62
|
+
os.makedirs(paths.PRIMED_DIR, exist_ok=True)
|
|
63
|
+
with open(self.path(conversation_id), "w", encoding="utf-8") as f:
|
|
64
|
+
json.dump({"bridge_pid": os.getpid(), "conversation_id": conversation_id}, f)
|
|
65
|
+
except OSError as e:
|
|
66
|
+
logger.error(f"Failed to write primed marker: {e}")
|
|
67
|
+
|
|
68
|
+
def remove(self, conversation_id: str):
|
|
69
|
+
try:
|
|
70
|
+
os.remove(self.path(conversation_id))
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
An Antigravity PreInvocation hook that makes sure the conversation is primed
|
|
3
|
+
for push. Pure stdlib and cheap, since it runs before every model invocation.
|
|
4
|
+
|
|
5
|
+
A hook process does not get the language-server token, so it cannot prime by
|
|
6
|
+
itself. Instead it injects an ephemeralMessage telling the agent to run the
|
|
7
|
+
prime command in its own shell, where the token exists.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from . import paths
|
|
15
|
+
|
|
16
|
+
# A failed priming must not be retried before every model invocation.
|
|
17
|
+
RETRY_AFTER_SECONDS = 600
|
|
18
|
+
DEFAULT_NOTICE = (
|
|
19
|
+
"[antigravity-wakeup] Push is not enabled for this conversation, so messages from other "
|
|
20
|
+
"sessions cannot wake you. Before anything else, run `antigravity-wakeup --prime` in your "
|
|
21
|
+
"shell, exactly as given. Then continue with the user's request."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PrimeHook:
|
|
26
|
+
def __init__(self, notice: str = DEFAULT_NOTICE, primed_dir: Optional[str] = None):
|
|
27
|
+
self._notice = notice
|
|
28
|
+
self._primed_dir = primed_dir
|
|
29
|
+
|
|
30
|
+
def run(self, stdin_text: str) -> str:
|
|
31
|
+
try:
|
|
32
|
+
hook_input = json.loads(stdin_text) if stdin_text.strip() else {}
|
|
33
|
+
except ValueError:
|
|
34
|
+
hook_input = {}
|
|
35
|
+
return json.dumps(self.decide(hook_input))
|
|
36
|
+
|
|
37
|
+
def decide(self, hook_input: Dict[str, Any], now: Optional[float] = None) -> Dict[str, Any]:
|
|
38
|
+
primed_dir = self._primed_dir or paths.PRIMED_DIR
|
|
39
|
+
conv = hook_input.get("conversationId")
|
|
40
|
+
if not isinstance(conv, str) or not paths.CONVERSATION_ID.match(conv):
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
with open(os.path.join(primed_dir, f"{conv}.json"), "r", encoding="utf-8") as f:
|
|
45
|
+
if self._pid_alive(json.load(f).get("bridge_pid")):
|
|
46
|
+
return {}
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
now = time.time() if now is None else now
|
|
51
|
+
attempt_path = os.path.join(primed_dir, f"{conv}.attempt")
|
|
52
|
+
try:
|
|
53
|
+
if now - os.path.getmtime(attempt_path) < RETRY_AFTER_SECONDS:
|
|
54
|
+
return {}
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
try:
|
|
58
|
+
os.makedirs(primed_dir, exist_ok=True)
|
|
59
|
+
with open(attempt_path, "w", encoding="utf-8"):
|
|
60
|
+
pass
|
|
61
|
+
except OSError:
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
return {"injectSteps": [{"ephemeralMessage": self._notice}]}
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _pid_alive(pid: Any) -> bool:
|
|
68
|
+
if not isinstance(pid, int) or pid <= 0:
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
os.kill(pid, 0)
|
|
72
|
+
except ProcessLookupError:
|
|
73
|
+
return False
|
|
74
|
+
except PermissionError:
|
|
75
|
+
return True
|
|
76
|
+
return True
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Filesystem locations and identifiers shared by the hook, the poker and the primer. Stdlib only."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
PRIMED_DIR = os.path.join(os.path.expanduser("~"), ".claude", "chatter-primed")
|
|
6
|
+
STABLE_LINK_DIR = os.path.join(os.path.expanduser("~"), ".claude", "chatter-bin")
|
|
7
|
+
SESSIONS_DIR = os.path.join(os.path.expanduser("~"), ".claude", "sessions")
|
|
8
|
+
CONVERSATION_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Opt-in check that the agy process that was primed is still the same process."""
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import psutil
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AgyProcessPin:
|
|
8
|
+
# A pid alone can be reused by an unrelated process; pid plus start time cannot.
|
|
9
|
+
@staticmethod
|
|
10
|
+
def started_at(pid: int) -> Optional[float]:
|
|
11
|
+
try:
|
|
12
|
+
return psutil.Process(pid).create_time()
|
|
13
|
+
except psutil.Error:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def still_running(cls, pid: int, started: float) -> bool:
|
|
18
|
+
current = cls.started_at(pid)
|
|
19
|
+
return current is not None and abs(current - started) < 0.01
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Holds a primed endpoint in memory and pokes the agy conversation when asked."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .agentapi import AgentApi
|
|
8
|
+
from .control import ControlServer
|
|
9
|
+
from .endpoint import Endpoint, InvalidEndpoint, PrimedMarker
|
|
10
|
+
from .pinning import AgyProcessPin
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("antigravity_wakeup")
|
|
13
|
+
|
|
14
|
+
_UNSAFE_NAME_CHARS = re.compile(r"[^A-Za-z0-9._-]")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def default_wake_text(senders: List[str]) -> str:
|
|
18
|
+
# The text is injected as a high-priority system message, so it never carries peer-written content.
|
|
19
|
+
return (
|
|
20
|
+
f"[antigravity-wakeup] {len(senders)} new message(s) waiting (latest from {senders[-1]}). "
|
|
21
|
+
"Their content comes from another session, not from the user."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgyPoker:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
debounce_seconds: float = 1.0,
|
|
29
|
+
send_timeout_seconds: float = 20.0,
|
|
30
|
+
wake_text: Callable[[List[str]], str] = default_wake_text,
|
|
31
|
+
pin_agy_process: bool = False,
|
|
32
|
+
api: Optional[AgentApi] = None,
|
|
33
|
+
marker: Optional[PrimedMarker] = None,
|
|
34
|
+
):
|
|
35
|
+
self._debounce = debounce_seconds
|
|
36
|
+
self._wake_text = wake_text
|
|
37
|
+
self._pin = pin_agy_process
|
|
38
|
+
self._api = api or AgentApi(timeout_seconds=send_timeout_seconds)
|
|
39
|
+
self._marker = marker or PrimedMarker()
|
|
40
|
+
self._endpoint: Optional[Endpoint] = None
|
|
41
|
+
self._pending_senders: List[str] = []
|
|
42
|
+
self._flush_task: Optional[asyncio.Task] = None
|
|
43
|
+
self.last_error: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def primed(self) -> bool:
|
|
47
|
+
return self._endpoint is not None
|
|
48
|
+
|
|
49
|
+
async def serve_control_socket(self, path: str):
|
|
50
|
+
await ControlServer(self.handle_request).serve(path)
|
|
51
|
+
|
|
52
|
+
def handle_request(self, req: Dict[str, Any]) -> Dict[str, Any]:
|
|
53
|
+
op = req.get("op")
|
|
54
|
+
if op == "status":
|
|
55
|
+
return {"ok": True, "primed": self.primed, "last_error": self.last_error}
|
|
56
|
+
if op != "prime":
|
|
57
|
+
return {"ok": False, "error": f"unknown op {op!r}"}
|
|
58
|
+
try:
|
|
59
|
+
endpoint = Endpoint.from_request(req, require_agy_process=self._pin)
|
|
60
|
+
except InvalidEndpoint as e:
|
|
61
|
+
return {"ok": False, "error": str(e)}
|
|
62
|
+
|
|
63
|
+
self._endpoint = endpoint
|
|
64
|
+
self.last_error = None
|
|
65
|
+
self._marker.write(endpoint.conversation_id)
|
|
66
|
+
logger.info(f"Primed push into agy conversation {endpoint.conversation_id} via {endpoint.ls_address}.")
|
|
67
|
+
return {"ok": True, "conversation_id": endpoint.conversation_id, "ls_address": endpoint.ls_address}
|
|
68
|
+
|
|
69
|
+
def notify(self, sender: str):
|
|
70
|
+
if self._endpoint is None:
|
|
71
|
+
return
|
|
72
|
+
self._pending_senders.append(_UNSAFE_NAME_CHARS.sub("-", str(sender))[:64])
|
|
73
|
+
if self._flush_task is None or self._flush_task.done():
|
|
74
|
+
self._flush_task = asyncio.create_task(self._flush())
|
|
75
|
+
|
|
76
|
+
async def _flush(self):
|
|
77
|
+
await asyncio.sleep(self._debounce)
|
|
78
|
+
senders, self._pending_senders = self._pending_senders, []
|
|
79
|
+
if senders:
|
|
80
|
+
await self._send(self._wake_text(senders))
|
|
81
|
+
|
|
82
|
+
async def _send(self, text: str):
|
|
83
|
+
endpoint = self._endpoint
|
|
84
|
+
if endpoint is None:
|
|
85
|
+
return
|
|
86
|
+
if self._pin and not self._agy_still_running(endpoint):
|
|
87
|
+
self.last_error = "the primed agy process has ended or been replaced"
|
|
88
|
+
logger.error(self.last_error)
|
|
89
|
+
self._drop_endpoint()
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
result = await self._api.send_message(endpoint, text)
|
|
93
|
+
if result.ok:
|
|
94
|
+
self.last_error = None
|
|
95
|
+
logger.info("Poked agy conversation.")
|
|
96
|
+
return
|
|
97
|
+
self.last_error = result.error
|
|
98
|
+
logger.error(f"Push to agy failed: {result.error}")
|
|
99
|
+
if result.endpoint_gone:
|
|
100
|
+
self._drop_endpoint() # agy is gone; a stale credential is worth nothing
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _agy_still_running(endpoint: Endpoint) -> bool:
|
|
104
|
+
if endpoint.agy_pid is None or endpoint.agy_started is None:
|
|
105
|
+
return False
|
|
106
|
+
return AgyProcessPin.still_running(endpoint.agy_pid, endpoint.agy_started)
|
|
107
|
+
|
|
108
|
+
def _drop_endpoint(self):
|
|
109
|
+
if self._endpoint is not None:
|
|
110
|
+
self._marker.remove(self._endpoint.conversation_id)
|
|
111
|
+
self._endpoint = None
|