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.
@@ -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,6 @@
1
+ """
2
+ Wake an idle Antigravity (agy) conversation from another process.
3
+
4
+ Kept free of imports on purpose: the PreInvocation hook runs before every model
5
+ invocation and must stay cheap.
6
+ """
@@ -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