gemini-computer-use 0.2.4 → 0.4.1

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.
Files changed (41) hide show
  1. package/README.md +733 -16
  2. package/bin/cli.js +112 -17
  3. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  4. package/core/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
  6. package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
  7. package/core/__pycache__/schemas.cpython-313.pyc +0 -0
  8. package/core/__pycache__/server.cpython-313.pyc +0 -0
  9. package/core/__pycache__/vitals.cpython-313.pyc +0 -0
  10. package/core/__pycache__/web_share.cpython-313.pyc +0 -0
  11. package/core/agent.py +503 -18
  12. package/core/agent_harness.py +9 -2
  13. package/core/code_cache.py +15 -3
  14. package/core/device.py +2400 -0
  15. package/core/domain.py +303 -0
  16. package/core/mcp_tools.py +1840 -112
  17. package/core/policies.py +35 -5
  18. package/core/server.py +78 -32
  19. package/core/termux.py +916 -0
  20. package/core/updater.py +1713 -0
  21. package/core/version.py +31 -0
  22. package/core/vitals.py +66 -2
  23. package/core/web_share.py +814 -0
  24. package/gateway.py +1469 -98
  25. package/install-gui.cmd +40 -0
  26. package/install-gui.ps1 +1882 -0
  27. package/install-gui.strings.json +211 -0
  28. package/install.ps1 +960 -92
  29. package/install.sh +1214 -81
  30. package/ops/doctor.ps1 +735 -0
  31. package/ops/mesh-domain.sh +86 -0
  32. package/ops/nginx/README.md +77 -0
  33. package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
  34. package/ops/nginx/mesh-domain.vhost.template +58 -0
  35. package/ops/nginx/render-domain.sh +123 -0
  36. package/ops/update.ps1 +373 -0
  37. package/ops/windows/README.md +298 -0
  38. package/ops/windows/agent-watchdog.ps1 +575 -0
  39. package/ops/windows/run-hidden.vbs +64 -0
  40. package/package.json +6 -2
  41. package/skills/orchestrator.md +12 -0
package/core/agent.py CHANGED
@@ -13,11 +13,17 @@ Configuration (read once at import/startup)::
13
13
  MESH_CONFIG_FILE KEY=VALUE file used for any of the three above that is not
14
14
  set in the environment (default:
15
15
  ~/.config/antigravity-mesh/agent.env). This keeps the token
16
- out of launchers such as the Windows Startup script.
16
+ out of launchers such as the Windows Startup script. Any
17
+ other MESH_* key in that file is exported as well.
18
+ MESH_SHELL Windows command shell: "cmd" or "git-bash" (default:
19
+ PowerShell, falling back to cmd.exe when PowerShell is absent)
17
20
  MESH_WORKSPACE base directory for relative paths (default: cwd)
18
21
  MESH_READ_ONLY "1"/"true" -> mutating tools refuse to run
19
22
  MESH_WRITE_ROOTS pathsep-separated list of roots allowed for writes
20
23
 
24
+ MESH_UPDATE_* self-update: see core/updater.py. A running node checks for a
25
+ newer GitHub release in the background and installs it
26
+
21
27
  Resilience contract:
22
28
 
23
29
  * every link failure (DNS, TLS, reset, half-open socket after sleep) is retried
@@ -31,16 +37,30 @@ Resilience contract:
31
37
  """
32
38
 
33
39
  import asyncio
40
+ import atexit
41
+ import faulthandler
34
42
  import json
35
43
  import logging
36
44
  import os
37
45
  import random
46
+ import signal
47
+ import socket
48
+ import subprocess
38
49
  import sys
50
+ import threading
39
51
  import time
52
+ from typing import Optional
40
53
 
41
- import websockets
54
+ try:
55
+ import websockets
56
+ except ImportError as _websockets_error: # a launcher pinned a Python without the dep
57
+ websockets = None # type: ignore[assignment]
58
+ _WEBSOCKETS_IMPORT_ERROR = _websockets_error
59
+ else:
60
+ _WEBSOCKETS_IMPORT_ERROR = None
42
61
 
43
- from core import mcp_tools
62
+ from core import domain, mcp_tools, updater
63
+ from core import version as version_module
44
64
 
45
65
  logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
46
66
  logger = logging.getLogger("agy-agent")
@@ -48,6 +68,16 @@ logger = logging.getLogger("agy-agent")
48
68
  CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "antigravity-mesh")
49
69
  DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "agent.env")
50
70
 
71
+ #: Diagnostics that answer "why is this node offline?" without the tunnel. The
72
+ #: watchdog (ops/windows/agent-watchdog.ps1) and ops/doctor.ps1 read these files,
73
+ #: and the incident that motivated them - a launcher pinned to the Microsoft
74
+ #: Store Python alias that left a single torn line in agent.log and kept the node
75
+ #: offline for a day - is exactly what they prevent.
76
+ HEARTBEAT_FILE = os.path.join(CONFIG_DIR, "agent.heartbeat")
77
+ FAULT_FILE = os.path.join(CONFIG_DIR, "agent.fault.log")
78
+ STOP_MARKER_FILE = os.path.join(CONFIG_DIR, "agent.stopped")
79
+ HEARTBEAT_INTERVAL = 15.0
80
+
51
81
  #: Backoff cap for ordinary link failures.
52
82
  MAX_BACKOFF = 15.0
53
83
  #: Wait after the gateway rejects the token: retrying fast cannot help, but the
@@ -56,19 +86,79 @@ UNAUTHORIZED_BACKOFF = 60.0
56
86
 
57
87
 
58
88
  def read_env_file(path: str) -> dict:
59
- """Parse a KEY=VALUE file; tolerates a UTF-8 BOM (Windows PowerShell 5 writes one)."""
60
- values = {}
89
+ """Parse a KEY=VALUE file; tolerates a UTF-8 BOM (Windows PowerShell 5 writes one).
90
+
91
+ The implementation lives in :mod:`core.domain`, which reads ``domain.env`` with
92
+ the same rules; it is re-exported here so existing importers of
93
+ ``core.agent.read_env_file`` keep working and the project keeps exactly one
94
+ parser.
95
+ """
96
+ return domain.read_env_file(path)
97
+
98
+
99
+ #: Hostnames that identify no machine. Android answers "localhost" to
100
+ #: ``gethostname(2)`` for every device, and Termux has no hostname of its own, so
101
+ #: a node that trusted it would collide with every other phone: the gateway keeps
102
+ #: exactly one tunnel per name and the newer one evicts the older.
103
+ _USELESS_HOSTNAMES = frozenset({"", "localhost", "localhost.localdomain", "android"})
104
+
105
+
106
+ def android_device_name() -> str:
107
+ """The phone's model as a node name (``Pixel 7 Pro`` -> ``pixel7pro``).
108
+
109
+ ``getprop`` ships with Android itself, needs no root and is on PATH inside
110
+ Termux. A device that does not answer yields "" and the caller falls back to
111
+ the generic name - this helper never raises.
112
+ """
113
+ for prop in ("ro.product.model", "ro.product.device"):
114
+ try:
115
+ completed = subprocess.run(["getprop", prop], capture_output=True, timeout=5)
116
+ except Exception:
117
+ continue
118
+ value = (completed.stdout or b"").decode("utf-8", "replace")
119
+ safe = "".join(c for c in value.strip().lower() if c.isalnum() or c in "-_")
120
+ if safe:
121
+ return safe
122
+ return ""
123
+
124
+
125
+ def default_node_name() -> str:
126
+ """Node name used when ``MESH_USER`` is not configured: the machine's name.
127
+
128
+ This used to be a personal nickname, so every node that did not set
129
+ ``MESH_USER`` claimed that *same* name on the shared gateway - and the gateway
130
+ keeps exactly one tunnel per name, evicting the previous one. Two unrelated
131
+ users would therefore knock each other offline in an endless loop, which is
132
+ precisely the flapping the instance lock exists to prevent (and that lock is
133
+ per machine, so it cannot help across machines). The installers already
134
+ register the sanitised hostname, so the agent now agrees with them.
135
+
136
+ On a phone the hostname is worse than useless - it is the same "localhost" for
137
+ every device - so Termux uses the device model instead, exactly as the
138
+ installer registers it.
139
+ """
61
140
  try:
62
- with open(path, "r", encoding="utf-8-sig") as handle:
63
- for line in handle:
64
- line = line.strip()
65
- if not line or line.startswith("#") or "=" not in line:
66
- continue
67
- key, _, value = line.partition("=")
68
- values[key.strip()] = value.strip().strip('"').strip("'")
69
- except OSError:
70
- pass
71
- return values
141
+ hostname = socket.gethostname()
142
+ except Exception:
143
+ hostname = ""
144
+ safe = "".join(c for c in hostname.strip().lower() if c.isalnum() or c in "-_")
145
+ if safe in _USELESS_HOSTNAMES and domain.is_termux():
146
+ safe = android_device_name()
147
+ return safe or "node"
148
+
149
+
150
+ def configuration_error() -> Optional[str]:
151
+ """Human-readable reason this node cannot connect, or None when it is usable.
152
+
153
+ An empty token is not a transient link failure: the gateway rejects the node
154
+ on every attempt, so retrying forever only fills agent.log and leaves a
155
+ broken autostart behind. The caller reports this and stops.
156
+ """
157
+ if not TOKEN:
158
+ return ("MESH_TOKEN is empty (environment and %s). The gateway rejects a node "
159
+ "without a token, so the tunnel cannot work - re-run the installer."
160
+ % (os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE))
161
+ return None
72
162
 
73
163
 
74
164
  def _load_settings() -> dict:
@@ -77,9 +167,22 @@ def _load_settings() -> dict:
77
167
  def pick(key: str, default: str) -> str:
78
168
  return os.environ.get(key) or file_values.get(key) or default
79
169
 
170
+ # Every other MESH_* key the operator put in the same file is exported too, so
171
+ # one agent.env (or one Windows Startup launcher) configures the whole node:
172
+ # MESH_SHELL for the Windows command shell, MESH_WORKSPACE, MESH_READ_ONLY,
173
+ # MESH_WRITE_ROOTS, MESH_JOBS_DIR, MESH_MAX_OUTPUT_CHARS. The real environment
174
+ # always wins over the file.
175
+ for key, value in file_values.items():
176
+ if key.startswith("MESH_") and not os.environ.get(key):
177
+ os.environ[key] = value
178
+
80
179
  return {
81
- "gateway": pick("MESH_GATEWAY", "smart-server.online"),
82
- "user": pick("MESH_USER", "levra7"),
180
+ # The host the node dials. core.domain owns the order: the configured
181
+ # public domain (MESH_PUBLIC_URL, or domain.env) wins, and the legacy
182
+ # MESH_GATEWAY - which the loop above has just exported from agent.env - is
183
+ # only consulted while nothing else names a domain.
184
+ "gateway": domain.gateway_host(),
185
+ "user": pick("MESH_USER", default_node_name()),
83
186
  "token": pick("MESH_TOKEN", ""),
84
187
  }
85
188
 
@@ -105,6 +208,26 @@ def configure_from_env() -> None:
105
208
  write_roots=write_roots,
106
209
  jobs_dir=jobs_dir,
107
210
  max_output_chars=max_chars,
211
+ # Public shares: the node name in a share link must be the name this
212
+ # tunnel registers with the gateway (the gateway routes
213
+ # /<node>/<slug>/... back to that tunnel), so it is passed explicitly
214
+ # rather than left to web_share's MESH_USER fallback - which would say
215
+ # "anonymous" whenever the operator relied on the hostname default.
216
+ # ``public_url`` is not passed here: core/domain.py owns the domain.
217
+ web_dir=os.environ.get("MESH_WEB_DIR") or None,
218
+ mesh_user=USER,
219
+ max_share_bytes=os.environ.get("MESH_WEB_MAX_BYTES"),
220
+ max_shares=os.environ.get("MESH_WEB_MAX_SHARES"),
221
+ web_listing=os.environ.get("MESH_WEB_LISTING"),
222
+ # Device branch (core/device.py, core/termux.py). "auto" is the default and
223
+ # keeps the tools advertised on every platform - the phone-only ones then
224
+ # answer with a reason instead of vanishing, because the gateway's static
225
+ # tool surface must match this node's exactly.
226
+ device=os.environ.get("MESH_DEVICE") or "auto",
227
+ device_actions=os.environ.get("MESH_DEVICE_ACTIONS") or None,
228
+ capture_dir=os.environ.get("MESH_DEVICE_CAPTURE_DIR") or None,
229
+ device_pim=_env_flag("MESH_DEVICE_PIM"),
230
+ device_quick=_env_flag("MESH_DEVICE_QUICK"),
108
231
  )
109
232
 
110
233
 
@@ -113,6 +236,302 @@ def configure_from_env() -> None:
113
236
  configure_from_env()
114
237
 
115
238
 
239
+ # ---------------------------------------------------------------------------
240
+ # Runtime diagnostics: banner, fault log, heartbeat, clean-stop marker
241
+ #
242
+ # A node that never connects leaves the client with an opaque frontend error, so
243
+ # every start records what it is: which interpreter, which websockets, from which
244
+ # directory. The heartbeat makes "the agent runs but is not connected" visible to
245
+ # the watchdog, and the absence of a clean-stop marker tells it that the process
246
+ # was killed instead of shutting down.
247
+ # ---------------------------------------------------------------------------
248
+
249
+ _RUNTIME_FACTS = None
250
+ _FAULT_HANDLE = None
251
+
252
+ #: Live state reported through the heartbeat. Written by the event loop and read
253
+ #: by the heartbeat thread; plain dict operations are atomic enough under the GIL
254
+ #: and a lost update only costs one cycle of freshness.
255
+ _HEARTBEAT_STATE = {
256
+ "connected": False,
257
+ "connects": 0,
258
+ "last_tool": "",
259
+ "last_tool_at": 0.0,
260
+ "last_error": "",
261
+ "started_at": 0.0,
262
+ }
263
+
264
+
265
+ def runtime_facts() -> dict:
266
+ """Interpreter, dependency and location facts of this agent process."""
267
+ global _RUNTIME_FACTS
268
+ if _RUNTIME_FACTS is None:
269
+ version = getattr(websockets, "__version__", "MISSING") if websockets else "MISSING"
270
+ _RUNTIME_FACTS = {
271
+ "python": "%d.%d.%d" % sys.version_info[:3],
272
+ "interpreter": sys.executable or "",
273
+ "websockets": version,
274
+ "node_version": version_module.__version__,
275
+ }
276
+ facts = dict(_RUNTIME_FACTS)
277
+ # Read fresh: tests (and a re-configured process) may change these.
278
+ facts["gateway"] = GATEWAY_HOST
279
+ facts["user"] = USER
280
+ return facts
281
+
282
+
283
+ def log_runtime_banner() -> dict:
284
+ """Log one line naming the interpreter a launcher actually started.
285
+
286
+ The Windows incident produced no such line: the launcher ran the Microsoft
287
+ Store alias, which printed ``Python `` and exited, so nothing in the log said
288
+ which interpreter was expected.
289
+ """
290
+ facts = runtime_facts()
291
+ logger.info("Runtime: node=%s interpreter=%s python=%s websockets=%s cwd=%s pid=%s",
292
+ facts.get("node_version") or version_module.__version__,
293
+ facts["interpreter"] or "<unknown>", facts["python"],
294
+ facts["websockets"], os.getcwd(), os.getpid())
295
+ if _WEBSOCKETS_IMPORT_ERROR is not None:
296
+ logger.error("The 'websockets' package is not importable by this interpreter (%s): %s. "
297
+ "The tunnel cannot start; run: %s -m pip install websockets",
298
+ facts["interpreter"] or "<unknown>", _WEBSOCKETS_IMPORT_ERROR,
299
+ facts["interpreter"] or "python")
300
+ if "\\WindowsApps\\" in facts["interpreter"] or "/WindowsApps/" in facts["interpreter"]:
301
+ logger.error("This agent runs through the Microsoft Store 'python.exe' alias (%s). That alias "
302
+ "stops working after a Store repair or an update; the installer must pin a real "
303
+ "interpreter instead.", facts["interpreter"])
304
+ return facts
305
+
306
+
307
+ def write_heartbeat(path: Optional[str] = None, **extra) -> dict:
308
+ """Atomically refresh the heartbeat file read by the watchdog and the doctor."""
309
+ target = path or HEARTBEAT_FILE
310
+ payload = {"ts": time.time(), "pid": os.getpid()}
311
+ payload.update(runtime_facts())
312
+ payload.update(_HEARTBEAT_STATE)
313
+ payload.update(extra)
314
+ try:
315
+ directory = os.path.dirname(target)
316
+ if directory:
317
+ os.makedirs(directory, exist_ok=True)
318
+ temporary = target + ".tmp"
319
+ with open(temporary, "w", encoding="utf-8") as handle:
320
+ json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
321
+ handle.write("\n")
322
+ os.replace(temporary, target)
323
+ except Exception as exc: # diagnostics never break the tunnel
324
+ logger.debug("Could not write the heartbeat %s: %s", target, exc)
325
+ return payload
326
+
327
+
328
+ def read_heartbeat(path: Optional[str] = None) -> dict:
329
+ """The last heartbeat, or an empty dict when there is none or it is unreadable."""
330
+ target = path or HEARTBEAT_FILE
331
+ try:
332
+ with open(target, "r", encoding="utf-8") as handle:
333
+ data = json.load(handle)
334
+ except Exception:
335
+ return {}
336
+ return data if isinstance(data, dict) else {}
337
+
338
+
339
+ def heartbeat_age(path: Optional[str] = None) -> float:
340
+ """Seconds since the last heartbeat; ``inf`` when there is none."""
341
+ try:
342
+ return max(0.0, time.time() - float(read_heartbeat(path).get("ts")))
343
+ except Exception:
344
+ return float("inf")
345
+
346
+
347
+ def mark_connected(connected: bool, reason: str = "") -> None:
348
+ """Record the tunnel state for the heartbeat."""
349
+ _HEARTBEAT_STATE["connected"] = bool(connected)
350
+ if connected:
351
+ _HEARTBEAT_STATE["connects"] = int(_HEARTBEAT_STATE.get("connects", 0)) + 1
352
+ _HEARTBEAT_STATE["last_error"] = ""
353
+ elif reason:
354
+ _HEARTBEAT_STATE["last_error"] = str(reason)[:300]
355
+
356
+
357
+ def start_heartbeat(path: Optional[str] = None, interval: float = HEARTBEAT_INTERVAL):
358
+ """Start the daemon heartbeat thread (it must never keep the process alive)."""
359
+ _HEARTBEAT_STATE["started_at"] = time.time()
360
+ write_heartbeat(path)
361
+
362
+ def _beat():
363
+ while True:
364
+ time.sleep(interval)
365
+ write_heartbeat(path)
366
+
367
+ thread = threading.Thread(target=_beat, name="mesh-heartbeat", daemon=True)
368
+ thread.start()
369
+ return thread
370
+
371
+
372
+ def enable_fault_log(path: Optional[str] = None) -> Optional[str]:
373
+ """Send fatal-error tracebacks (native crash, deadlock) to their own file.
374
+
375
+ Without this the only trace of an abnormal end is a torn line in agent.log.
376
+ """
377
+ global _FAULT_HANDLE
378
+ target = path or FAULT_FILE
379
+ try:
380
+ directory = os.path.dirname(target)
381
+ if directory:
382
+ os.makedirs(directory, exist_ok=True)
383
+ _FAULT_HANDLE = open(target, "a", encoding="utf-8")
384
+ faulthandler.enable(file=_FAULT_HANDLE, all_threads=True)
385
+ except Exception as exc:
386
+ logger.debug("Could not enable the fault log %s: %s", target, exc)
387
+ return None
388
+ return target
389
+
390
+
391
+ def write_stop_marker(reason: str = "stop") -> None:
392
+ """Record a clean stop: a missing marker means the process was killed."""
393
+ try:
394
+ directory = os.path.dirname(STOP_MARKER_FILE)
395
+ if directory:
396
+ os.makedirs(directory, exist_ok=True)
397
+ with open(STOP_MARKER_FILE, "w", encoding="utf-8") as handle:
398
+ handle.write("%s pid=%s reason=%s\n"
399
+ % (time.strftime("%Y-%m-%dT%H:%M:%S"), os.getpid(), reason))
400
+ except Exception:
401
+ pass
402
+
403
+
404
+ def install_exit_markers() -> None:
405
+ """Write the clean-stop marker on a normal exit or on a received signal."""
406
+ atexit.register(write_stop_marker, "atexit")
407
+
408
+ def _on_signal(signum, _frame):
409
+ write_stop_marker("signal=%s" % signum)
410
+ raise SystemExit(0)
411
+
412
+ for name in ("SIGINT", "SIGTERM", "SIGBREAK"):
413
+ number = getattr(signal, name, None)
414
+ if number is None:
415
+ continue
416
+ try:
417
+ signal.signal(number, _on_signal)
418
+ except Exception:
419
+ continue
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Self-update
424
+ #
425
+ # The updater itself lives in core/updater.py and is deliberately unaware of this
426
+ # module: it can check, download, verify and swap a payload with no agent around
427
+ # (ops/update.ps1 and the CLI drive it that way). What only the agent can do is
428
+ # stop cleanly so the swapped code is what runs next - that is the hook below.
429
+ # ---------------------------------------------------------------------------
430
+
431
+ #: The updater's last answer, carried in every heartbeat so ops/doctor.ps1 can
432
+ #: show "a newer release exists" (or "the last check failed") without a network.
433
+ _UPDATE_STATE: dict = {}
434
+ _RESTART_EVENT = threading.Event()
435
+
436
+
437
+ def note_update_state(result: dict) -> None:
438
+ """Record one updater answer in the heartbeat."""
439
+ if not isinstance(result, dict):
440
+ return
441
+ check = result.get("check") if isinstance(result.get("check"), dict) else result
442
+ payload = {
443
+ "current": check.get("current") or version_module.__version__,
444
+ "latest": check.get("latest") or "",
445
+ "available": bool(check.get("update_available")),
446
+ "checked_at": check.get("checked_at") or "",
447
+ "reason": str(check.get("reason") or "")[:200],
448
+ }
449
+ applied = result.get("apply")
450
+ if isinstance(applied, dict) and applied.get("applied"):
451
+ payload["applied"] = applied.get("to") or ""
452
+ _UPDATE_STATE.clear()
453
+ _UPDATE_STATE.update(payload)
454
+ _HEARTBEAT_STATE["update"] = dict(_UPDATE_STATE)
455
+ write_heartbeat()
456
+
457
+
458
+ def request_restart(delay: float = 5.0) -> bool:
459
+ """Stop this agent (once) so the restart helper can start the updated copy.
460
+
461
+ The delay is what lets an in-flight tool result reach the caller: a
462
+ ``mesh_update`` call answers "applied and restarting" and only then does the
463
+ process leave, which is also what releases the tunnel and the instance lock
464
+ the new agent needs.
465
+ """
466
+ if _RESTART_EVENT.is_set():
467
+ return False
468
+ _RESTART_EVENT.set()
469
+ name = "SIGBREAK" if os.name == "nt" else "SIGTERM"
470
+ number = getattr(signal, name, None)
471
+ if number is None:
472
+ number = getattr(signal, "SIGTERM", None)
473
+
474
+ def _fire():
475
+ time.sleep(max(0.0, delay))
476
+ logger.warning("Stopping this agent so the updated version can start (signal %s).", name)
477
+ try:
478
+ signal.raise_signal(number)
479
+ except Exception:
480
+ # A platform without raise_signal still has to end up updated; the
481
+ # restart helper is already waiting for this pid either way.
482
+ os._exit(0)
483
+
484
+ threading.Thread(target=_fire, name="mesh-restart", daemon=True).start()
485
+ return True
486
+
487
+
488
+ def start_update_thread():
489
+ """Check for a newer release in the background and install it.
490
+
491
+ The first check is delayed (``MESH_UPDATE_INITIAL_DELAY``, 90 s by default)
492
+ so a node that was just started - including one that was just updated - gets
493
+ its tunnel up and serves a few calls before anything restarts.
494
+ """
495
+ config = updater.config_from_env()
496
+ if not config["enabled"]:
497
+ logger.info("Self-update is disabled (MESH_UPDATE_CHECK=0).")
498
+ return None
499
+ # Registered even when automatic updating is off: an operator (or the model
500
+ # through mesh_update) can still ask for an update, and the node then has to
501
+ # stop itself for the new version to run.
502
+ updater.set_restart_hook(request_restart)
503
+ try:
504
+ initial = float(os.environ.get("MESH_UPDATE_INITIAL_DELAY", "") or 90.0)
505
+ except ValueError:
506
+ initial = 90.0
507
+
508
+ def _loop():
509
+ time.sleep(max(0.0, initial))
510
+ while True:
511
+ try:
512
+ result = updater.background_tick(config=config)
513
+ note_update_state(result)
514
+ check = result.get("check") or {}
515
+ applied = result.get("apply") or {}
516
+ if applied.get("applied"):
517
+ logger.warning("Self-update: %s -> %s applied from %s; restarting.",
518
+ applied.get("from"), applied.get("to"), applied.get("asset"))
519
+ elif check.get("update_available"):
520
+ logger.info("Self-update: release %s is available (%s).",
521
+ check.get("latest"), check.get("reason"))
522
+ elif check.get("ok"):
523
+ logger.debug("Self-update: up to date (%s).", check.get("current"))
524
+ else:
525
+ logger.warning("Self-update check failed: %s", check.get("reason"))
526
+ except Exception as exc: # the updater must never take the node down
527
+ logger.warning("Self-update iteration failed: %s", exc)
528
+ time.sleep(max(60, int(config["interval"])))
529
+
530
+ thread = threading.Thread(target=_loop, name="mesh-updater", daemon=True)
531
+ thread.start()
532
+ return thread
533
+
534
+
116
535
  # ---------------------------------------------------------------------------
117
536
  # Single instance per node name
118
537
  # ---------------------------------------------------------------------------
@@ -171,7 +590,14 @@ def wait_for_instance_lock(user: str) -> InstanceLock:
171
590
  async def handle_tool_call(name: str, args: dict) -> dict:
172
591
  """Forward one tool call to the shared implementation in a worker thread."""
173
592
  logger.info("Executing %s", name)
174
- return await asyncio.to_thread(mcp_tools.call_tool, name, args or {})
593
+ _HEARTBEAT_STATE["last_tool"] = name or ""
594
+ _HEARTBEAT_STATE["last_tool_at"] = time.time()
595
+ try:
596
+ return await asyncio.to_thread(mcp_tools.call_tool, name, args or {})
597
+ finally:
598
+ # A heartbeat right after every call makes "the agent is alive and served
599
+ # this tool" visible to the watchdog without reading the tunnel state.
600
+ write_heartbeat()
175
601
 
176
602
 
177
603
  async def _serve_call(ws, req_id, params: dict) -> None:
@@ -182,6 +608,7 @@ async def _serve_call(ws, req_id, params: dict) -> None:
182
608
  except Exception as tool_exc:
183
609
  # A tool must never take the tunnel down with it.
184
610
  logger.warning(f"Tool {tool_name} failed: {tool_exc}")
611
+ _HEARTBEAT_STATE["last_error"] = "%s: %s" % (type(tool_exc).__name__, tool_exc)
185
612
  res = {"error": f"{type(tool_exc).__name__}: {tool_exc}"}
186
613
  try:
187
614
  await ws.send(json.dumps({"id": req_id, "result": res}, ensure_ascii=False))
@@ -189,6 +616,8 @@ async def _serve_call(ws, req_id, params: dict) -> None:
189
616
  # The link dropped while the tool ran; the gateway already failed the
190
617
  # call, and the reconnect loop restores the tunnel.
191
618
  logger.warning(f"Could not deliver result of {tool_name}: {send_exc}")
619
+ _HEARTBEAT_STATE["last_error"] = "send failed: %s" % send_exc
620
+ write_heartbeat()
192
621
 
193
622
 
194
623
  def _is_unauthorized(exc: BaseException) -> bool:
@@ -212,6 +641,25 @@ def _is_unauthorized(exc: BaseException) -> bool:
212
641
  return status in (401, 403)
213
642
 
214
643
 
644
+ def _looks_like_dns_failure(exc: BaseException) -> bool:
645
+ """True when the connection failed because the gateway name did not resolve.
646
+
647
+ A wrong or dead gateway domain produced an endless stream of generic
648
+ "Connection lost" lines, which reads like a network outage. Naming the cause
649
+ once is the difference between a five-minute fix and a long hunt.
650
+ """
651
+ if isinstance(exc, socket.gaierror):
652
+ return True
653
+ text = str(exc).lower()
654
+ return any(marker in text for marker in (
655
+ "name or service not known", # Linux getaddrinfo
656
+ "nodename nor servname", # BSD / macOS
657
+ "getaddrinfo failed", # Windows
658
+ "temporary failure in name resolution",
659
+ "no address associated with hostname",
660
+ ))
661
+
662
+
215
663
  def tunnel_uri() -> str:
216
664
  """Canonical tunnel URL: one shared domain, node selected by ?user=."""
217
665
  base = GATEWAY_HOST.rstrip("/")
@@ -242,6 +690,8 @@ async def run_agent():
242
690
  max_size=None) as ws:
243
691
  logger.info(f"Connected to Mesh Gateway as '{USER}'!")
244
692
  backoff = 1.0 # healthy again
693
+ mark_connected(True)
694
+ write_heartbeat()
245
695
  async for raw_msg in ws:
246
696
  try:
247
697
  data = json.loads(raw_msg)
@@ -259,10 +709,19 @@ async def run_agent():
259
709
  except asyncio.CancelledError:
260
710
  raise
261
711
  except Exception as e:
712
+ # The heartbeat is how a watchdog - and an operator reading the files
713
+ # later - can tell "running but disconnected" from "not running".
714
+ mark_connected(False, "%s: %s" % (type(e).__name__, e))
715
+ write_heartbeat()
262
716
  if _is_unauthorized(e):
263
717
  delay = UNAUTHORIZED_BACKOFF
264
718
  logger.error("Gateway rejected node '%s' (4001 Unauthorized): the token is wrong or the "
265
719
  "node was removed. Re-run the installer. Retrying in %.0fs.", USER, delay)
720
+ elif _looks_like_dns_failure(e):
721
+ delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
722
+ logger.error("Gateway '%s' does not resolve (%s). Check the gateway name (MESH_GATEWAY) "
723
+ "and this machine's DNS. Retrying in %.0fs.", GATEWAY_HOST, e, delay)
724
+ backoff = min(backoff * 2, MAX_BACKOFF)
266
725
  else:
267
726
  delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
268
727
  logger.warning(f"Connection lost: {e}. Reconnecting in {delay:.1f}s...")
@@ -271,11 +730,37 @@ async def run_agent():
271
730
 
272
731
 
273
732
  def main() -> None:
733
+ # Fail fast on a configuration that can never work, instead of retrying the
734
+ # gateway every 60 seconds forever from a Windows Startup launcher.
735
+ problem = configuration_error()
736
+ if problem:
737
+ logger.error("%s Refusing to start.", problem)
738
+ sys.exit(2)
739
+ install_exit_markers()
740
+ enable_fault_log()
741
+ log_runtime_banner()
742
+ # An update that was interrupted mid-swap (a kill, a power cut) is settled
743
+ # here, before the tunnel starts: either finished or rolled back.
744
+ recovery = updater.recover_pending()
745
+ if recovery:
746
+ logger.warning("Self-update recovery: %s", recovery)
747
+ start_heartbeat()
748
+ if _WEBSOCKETS_IMPORT_ERROR is not None:
749
+ # The banner above already named the interpreter and the missing module.
750
+ logger.error("Refusing to start: this interpreter cannot import 'websockets'.")
751
+ sys.exit(3)
274
752
  _lock = wait_for_instance_lock(USER) # held for the life of the process
753
+ write_heartbeat()
754
+ logger.info("Instance lock acquired: this is the only agent for node '%s'.", USER)
755
+ # Only the agent that owns the node updates it: a second instance waiting for
756
+ # the lock must not swap files under the process that is actually running.
757
+ start_update_thread()
275
758
  try:
276
759
  asyncio.run(run_agent())
277
760
  except KeyboardInterrupt:
278
761
  sys.exit(0)
762
+ finally:
763
+ write_heartbeat(stopping=True)
279
764
 
280
765
 
281
766
  if __name__ == "__main__":
@@ -7,8 +7,15 @@ from core.triggers import create_mesh_watchdog_trigger, create_config_trigger
7
7
  from core.policies import get_mesh_policies, ALLOWED_WORKSPACES
8
8
  from core.subagents import get_mesh_subagents, get_root_capabilities_config
9
9
 
10
- DEFAULT_SAVE_DIR = "/root/agy-gdrive-runner/.state/sessions"
11
- DEFAULT_APP_DATA_DIR = "/root/agy-gdrive-runner/.state/artifacts"
10
+ # Derived from the coordinator workspace so the harness works wherever that root
11
+ # is: the documented /root/agy-gdrive-runner on Linux, %USERPROFILE%\agy-gdrive-runner
12
+ # on Windows. A hardcoded POSIX literal was rejected there by the SDK
13
+ # ("app_data_dir must be an absolute path") because os.path.isabs('/root/...') is
14
+ # False on Windows. os.path.abspath keeps it absolute even if the operator pointed
15
+ # MESH_COORDINATOR_WORKSPACE at a relative path.
16
+ _COORDINATOR_ROOT = os.path.abspath(ALLOWED_WORKSPACES[0])
17
+ DEFAULT_SAVE_DIR = os.path.join(_COORDINATOR_ROOT, ".state", "sessions")
18
+ DEFAULT_APP_DATA_DIR = os.path.join(_COORDINATOR_ROOT, ".state", "artifacts")
12
19
 
13
20
  ROOT_SYSTEM_INSTRUCTIONS = (
14
21
  "You are the Root Orchestrator for the Antigravity Remote Execution Mesh. "