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/mcp_tools.py CHANGED
@@ -29,6 +29,7 @@ import fnmatch
29
29
  import glob as _glob
30
30
  import hashlib
31
31
  import json
32
+ import locale
32
33
  import os
33
34
  import re
34
35
  import shutil
@@ -42,6 +43,13 @@ import uuid
42
43
  from datetime import datetime, timezone
43
44
  from typing import Any, Callable, Dict, List, Optional, Tuple
44
45
 
46
+ #: The public domain is owned by core/domain.py; this module asks for it instead of
47
+ #: keeping a fallback chain of its own. Standard library only, so importing it here
48
+ #: costs nothing and cannot fail for a missing third-party package.
49
+ from core import domain
50
+ from core import updater
51
+ from core import version as version_module
52
+
45
53
  # ---------------------------------------------------------------------------
46
54
  # Optional dependency: the host vitals collector. Kept optional so the module
47
55
  # stays importable under a bare interpreter.
@@ -51,6 +59,33 @@ try: # pragma: no cover - exercised implicitly by the environment
51
59
  except Exception: # pragma: no cover
52
60
  _core_get_host_vitals = None # type: ignore
53
61
 
62
+ # ---------------------------------------------------------------------------
63
+ # Optional dependencies of the device branch.
64
+ #
65
+ # ``core/device.py`` is the platform telemetry layer and ``core/termux.py`` the
66
+ # Termux:API adapter. Both are standard-library-only, but they are imported
67
+ # defensively for the same reason as the vitals collector above: a checkout that
68
+ # is missing one file must still serve every non-device tool instead of failing
69
+ # to import at all.
70
+ # ---------------------------------------------------------------------------
71
+ try: # pragma: no cover - the file ships with the node
72
+ from core import device as _device # type: ignore
73
+ except Exception: # pragma: no cover
74
+ _device = None # type: ignore
75
+
76
+ try: # pragma: no cover - the file ships with the node
77
+ from core import termux as _termux # type: ignore
78
+ except Exception: # pragma: no cover
79
+ _termux = None # type: ignore
80
+
81
+ # Optional: the public file-share server. Not every checkout carries it, so the
82
+ # import is guarded - when it is present it is configured with the same public
83
+ # domain as everything else, from core/domain.py.
84
+ try: # pragma: no cover - core/web_share.py is optional
85
+ from core import web_share as _web_share # type: ignore
86
+ except Exception: # pragma: no cover
87
+ _web_share = None # type: ignore
88
+
54
89
 
55
90
  # ---------------------------------------------------------------------------
56
91
  # Platform / command shell
@@ -66,6 +101,18 @@ if _IS_WINDOWS: # pragma: no cover - Windows only
66
101
  subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
67
102
 
68
103
  _SHELL_CACHE: Optional[Tuple[str, List[str]]] = None
104
+ #: The ``MESH_SHELL`` value ``_SHELL_CACHE`` was built from. Caching alone would
105
+ #: freeze whatever the environment said at the first tool call, so a node that
106
+ #: exports ``MESH_SHELL`` later (agent.env is read at startup, but a launcher may
107
+ #: set it after the module is imported) would keep the wrong shell forever.
108
+ _SHELL_CACHE_KEY: Optional[str] = None
109
+
110
+
111
+ def _shell_override() -> str:
112
+ """The operator's ``MESH_SHELL`` choice, normalised (empty when unset)."""
113
+ if not _IS_WINDOWS:
114
+ return ""
115
+ return os.environ.get("MESH_SHELL", "").strip().lower()
69
116
 
70
117
 
71
118
  def _git_bash_windows() -> Optional[str]:
@@ -88,21 +135,67 @@ def _git_bash_windows() -> Optional[str]:
88
135
  return None
89
136
 
90
137
 
138
+ def powershell_argv() -> Optional[List[str]]:
139
+ """argv prefix of PowerShell, or None when it is not installed.
140
+
141
+ ``pwsh`` (PowerShell 7) wins over ``powershell`` (5.1) when both exist; the
142
+ flags make a one-shot command behave like a script call: no profile, no
143
+ banner, no interactive prompt and no execution-policy surprise.
144
+ """
145
+ ps = shutil.which("pwsh") or shutil.which("powershell")
146
+ if not ps:
147
+ return None
148
+ return [ps, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command"]
149
+
150
+
151
+ def cmd_argv() -> List[str]:
152
+ """argv prefix of cmd.exe (present on every Windows). ``/d`` skips AutoRun,
153
+ ``/s`` keeps the quoting of the whole command intact, ``/c`` runs and exits."""
154
+ return [os.environ.get("COMSPEC") or "cmd.exe", "/d", "/s", "/c"]
155
+
156
+
157
+ def default_windows_shell() -> Tuple[str, List[str]]:
158
+ """PowerShell when present, cmd.exe otherwise.
159
+
160
+ Windows never defaults to bash: a stock box has no bash, the ``bash`` on PATH
161
+ is usually the WSL launcher (a different filesystem and process namespace),
162
+ and Git Bash is a third-party extra. Both cmd.exe and PowerShell ship with
163
+ the OS, and PowerShell can do everything cmd.exe can, so it is the default.
164
+ """
165
+ ps = powershell_argv()
166
+ if ps:
167
+ return ("powershell", ps)
168
+ return ("cmd", cmd_argv())
169
+
170
+
91
171
  def command_shell() -> Tuple[str, List[str]]:
92
- """(name, argv prefix) of the shell that runs bash_exec / run_job commands."""
93
- global _SHELL_CACHE
94
- if _SHELL_CACHE is None:
172
+ """(name, argv prefix) of the shell that runs bash_exec / run_job commands.
173
+
174
+ Windows: PowerShell by default, cmd.exe when PowerShell is missing, bash only
175
+ when the operator asks for it. ``MESH_SHELL`` selects explicitly:
176
+
177
+ MESH_SHELL=cmd cmd.exe (native syntax, no PowerShell startup cost)
178
+ MESH_SHELL=git-bash Git for Windows' bash, when it is installed
179
+ (unset) PowerShell, else cmd.exe
180
+
181
+ The choice is cached per ``MESH_SHELL`` value, so changing the variable (or
182
+ reading it from ``agent.env`` after the module was imported) takes effect.
183
+ """
184
+ global _SHELL_CACHE, _SHELL_CACHE_KEY
185
+ override = _shell_override()
186
+ if _SHELL_CACHE is None or _SHELL_CACHE_KEY != override:
95
187
  if _IS_WINDOWS:
96
- bash = _git_bash_windows()
97
- if bash:
98
- _SHELL_CACHE = ("git-bash", [bash, "-c"])
188
+ if override in ("cmd", "cmd.exe"):
189
+ _SHELL_CACHE = ("cmd", cmd_argv())
190
+ elif override in ("git-bash", "gitbash", "bash"):
191
+ bash = _git_bash_windows()
192
+ _SHELL_CACHE = ("git-bash", [bash, "-c"]) if bash else default_windows_shell()
99
193
  else:
100
- ps = shutil.which("pwsh") or shutil.which("powershell") or "powershell"
101
- _SHELL_CACHE = ("powershell", [ps, "-NoLogo", "-NoProfile", "-NonInteractive",
102
- "-ExecutionPolicy", "Bypass", "-Command"])
194
+ _SHELL_CACHE = default_windows_shell()
103
195
  else:
104
196
  bash = shutil.which("bash")
105
197
  _SHELL_CACHE = ("bash", [bash, "-c"]) if bash else ("sh", ["/bin/sh", "-c"])
198
+ _SHELL_CACHE_KEY = override
106
199
  return _SHELL_CACHE
107
200
 
108
201
 
@@ -137,6 +230,25 @@ _READ_ONLY = False
137
230
  _WRITE_ROOTS: Optional[List[str]] = None
138
231
  _JOBS_DIR = os.path.realpath(os.path.expanduser(_DEFAULT_JOBS_DIR))
139
232
  _MAX_OUTPUT_CHARS = _DEFAULT_MAX_OUTPUT_CHARS
233
+ #: Explicit public base URL for links this node hands out. ``None`` means "ask
234
+ #: core/domain.py", which is the normal case.
235
+ _PUBLIC_URL: Optional[str] = None
236
+
237
+ #: Device branch switches (``MESH_DEVICE*``, see :func:`configure`):
238
+ #: ``_DEVICE_ENABLED`` is ``None`` for "auto" (the branch answers everywhere and
239
+ #: the phone-only actions explain themselves on a desktop), ``False`` when the
240
+ #: operator turned it off, ``True`` when it was forced on.
241
+ _DEVICE_ENABLED: Optional[bool] = None
242
+ #: Optional allowlist of action names for the three action tools. ``None`` means
243
+ #: every action the build implements.
244
+ _DEVICE_ACTIONS: Optional[List[str]] = None
245
+ #: SMS / call log / contacts / placing a call. Off by default: the phone is often
246
+ #: a shared device and this is the operator's private data.
247
+ _DEVICE_PIM = False
248
+ #: Keep ``system_info`` instant by skipping network/cameras/microphones/sensors.
249
+ _DEVICE_QUICK = False
250
+ #: Wall-clock budget for the device blocks inside ``system_info``.
251
+ DEVICE_DEADLINE = 7.0
140
252
 
141
253
  _META_LOCK = threading.RLock()
142
254
  _JOBS: Dict[str, Dict[str, Any]] = {}
@@ -175,9 +287,18 @@ def configure(**kwargs: Any) -> None:
175
287
  """(Re)configure the module. All keys are optional.
176
288
 
177
289
  Recognised keys: ``workspace``, ``read_only``, ``allow_write``,
178
- ``write_roots``, ``jobs_dir``, ``max_output_chars``.
290
+ ``write_roots``, ``jobs_dir``, ``max_output_chars``, ``public_url``,
291
+ the web-share keys ``web_dir``, ``mesh_user``, ``max_share_bytes``,
292
+ ``max_shares``, ``web_listing``, and the device-branch keys ``device``
293
+ (``auto``/``0``/``1``), ``device_actions``, ``capture_dir``, ``device_pim``
294
+ and ``device_quick``.
295
+
296
+ ``public_url`` overrides the public domain for links this node publishes;
297
+ leaving it out keeps the value that :mod:`core.domain` resolves (environment,
298
+ then the domain file, then the one default).
179
299
  """
180
300
  global _WORKSPACE, _READ_ONLY, _WRITE_ROOTS, _JOBS_DIR, _MAX_OUTPUT_CHARS
301
+ global _PUBLIC_URL, _DEVICE_ENABLED, _DEVICE_ACTIONS, _DEVICE_PIM, _DEVICE_QUICK
181
302
 
182
303
  if kwargs.get("workspace"):
183
304
  _WORKSPACE = os.path.realpath(os.path.expanduser(str(kwargs["workspace"])))
@@ -196,6 +317,72 @@ def configure(**kwargs: Any) -> None:
196
317
  _MAX_OUTPUT_CHARS = value
197
318
  except (TypeError, ValueError):
198
319
  pass
320
+ if "public_url" in kwargs:
321
+ _PUBLIC_URL = domain.normalise_public_base_url(kwargs.get("public_url")) or None
322
+
323
+ # --- device branch ------------------------------------------------------
324
+ # ``device=auto`` (the default) keeps the branch advertised everywhere: the
325
+ # tool surface is declared by the gateway for EVERY node, so a phone tool that
326
+ # vanished on a desktop would break the node/gateway contract. What changes per
327
+ # platform is the answer, not the presence of the tool.
328
+ if "device" in kwargs and kwargs["device"] is not None:
329
+ value = kwargs["device"]
330
+ if isinstance(value, bool):
331
+ _DEVICE_ENABLED = value
332
+ else:
333
+ text = str(value).strip().lower()
334
+ if text in ("auto", ""):
335
+ _DEVICE_ENABLED = None
336
+ elif text in ("1", "true", "yes", "on", "y", "t"):
337
+ _DEVICE_ENABLED = True
338
+ else:
339
+ _DEVICE_ENABLED = False
340
+ if "device_actions" in kwargs:
341
+ raw = kwargs.get("device_actions")
342
+ if raw is None or (isinstance(raw, str) and not raw.strip()):
343
+ _DEVICE_ACTIONS = None
344
+ else:
345
+ parts = raw.replace(";", ",").split(",") if isinstance(raw, str) else list(raw)
346
+ actions = [str(part).strip().lower() for part in parts if str(part).strip()]
347
+ _DEVICE_ACTIONS = actions or None
348
+ if "device_pim" in kwargs and kwargs["device_pim"] is not None:
349
+ _DEVICE_PIM = _parse_bool(kwargs["device_pim"])
350
+ if "device_quick" in kwargs and kwargs["device_quick"] is not None:
351
+ _DEVICE_QUICK = _parse_bool(kwargs["device_quick"])
352
+ if "capture_dir" in kwargs and _device is not None:
353
+ try:
354
+ _device.configure(capture_dir=kwargs.get("capture_dir") or None)
355
+ except Exception:
356
+ pass
357
+
358
+ # The public file-share server publishes links; it takes its base from the same
359
+ # single source as everything else (core/web_share.py ships only with some
360
+ # checkouts, hence the guard). Only an *explicit* ``public_url`` is pushed down:
361
+ # with no override web_share asks core.domain itself, so editing MESH_PUBLIC_URL
362
+ # or domain.env moves share links without re-configuring anything, and no second
363
+ # copy of the fallback chain appears here. An empty string means "no override",
364
+ # which is web_share's reset value.
365
+ if _web_share is not None:
366
+ try:
367
+ _web_share.configure(
368
+ web_dir=kwargs.get("web_dir"),
369
+ public_url=_PUBLIC_URL or "",
370
+ user=kwargs.get("mesh_user"),
371
+ max_bytes=kwargs.get("max_share_bytes"),
372
+ max_shares=kwargs.get("max_shares"),
373
+ listing=kwargs.get("web_listing"),
374
+ )
375
+ except Exception:
376
+ pass
377
+
378
+
379
+ def public_base_url() -> str:
380
+ """Public base URL this node publishes, e.g. ``https://mesh.example.com``.
381
+
382
+ An explicit ``configure(public_url=...)`` wins; otherwise the answer comes from
383
+ :mod:`core.domain`, so gateway, node and share links never disagree.
384
+ """
385
+ return _PUBLIC_URL or domain.public_base_url()
199
386
 
200
387
 
201
388
  # ---------------------------------------------------------------------------
@@ -287,6 +474,114 @@ def _sanitize_output(text: str) -> str:
287
474
  )
288
475
 
289
476
 
477
+ # ---------------------------------------------------------------------------
478
+ # Child-process output decoding
479
+ #
480
+ # A captured pipe carries bytes, and on Windows the same node can see three
481
+ # different encodings in one session: cmd.exe and PowerShell builtins write the
482
+ # OEM console code page (cp866 on a Russian box, cp437/850 elsewhere), most
483
+ # native tools write the ANSI code page (cp1251 here), and tools that opt into
484
+ # UTF-8 (git, curl, node, python with PYTHONUTF8) write UTF-8. Decoding with
485
+ # ``locale.getpreferredencoding()`` — the default of ``text=True`` — silently
486
+ # turned cp866 ``echo привет`` into ``ЇаЁўҐв`` and UTF-8 output into ``привет``.
487
+ # There is no in-band marker to trust, so try the encodings in order of how
488
+ # unambiguous they are and fall back only when a decode actually fails.
489
+ # ---------------------------------------------------------------------------
490
+
491
+ _DECODING_CACHE: Optional[List[str]] = None
492
+
493
+
494
+ def _windows_code_pages() -> List[str]:
495
+ """Code pages a Windows child may write: OEM console CP first, then ANSI."""
496
+ pages: List[str] = []
497
+ try:
498
+ import ctypes
499
+
500
+ for getter in ("GetOEMCP", "GetACP"):
501
+ try:
502
+ code_page = int(getattr(ctypes.windll.kernel32, getter)())
503
+ except Exception:
504
+ continue
505
+ if code_page > 0:
506
+ pages.append("cp%d" % code_page)
507
+ except Exception:
508
+ pass
509
+ return pages
510
+
511
+
512
+ def _output_decodings() -> List[str]:
513
+ """Candidate encodings for captured child output, most likely first.
514
+
515
+ Order matters and single-byte code pages never fail to decode, so this is a
516
+ priority list rather than a real fallback chain:
517
+
518
+ 1. UTF-8 - what git, curl, node and (with the environment below) python emit.
519
+ 2. The OEM console code page - what cmd.exe and PowerShell write, both for
520
+ their own messages and for the native console tools they launch.
521
+ 3. The ANSI code page - the last resort for a program using the ANSI API.
522
+ """
523
+ candidates = ["utf-8"]
524
+ if _IS_WINDOWS:
525
+ candidates.extend(_windows_code_pages())
526
+ else:
527
+ try:
528
+ candidates.append(locale.getpreferredencoding(False))
529
+ except Exception:
530
+ pass
531
+
532
+ unique: List[str] = []
533
+ seen = set()
534
+ for name in candidates:
535
+ if not name:
536
+ continue
537
+ key = name.lower()
538
+ if key in seen:
539
+ continue
540
+ seen.add(key)
541
+ unique.append(name)
542
+ return unique
543
+
544
+
545
+ def _child_env() -> Dict[str, str]:
546
+ """Environment for a child shell.
547
+
548
+ On Windows a Python child whose stdout is a pipe encodes with the *ANSI* code
549
+ page, which is neither UTF-8 nor the OEM code page the surrounding shell uses.
550
+ Telling python children to speak UTF-8 removes that third, undetectable case;
551
+ an operator's explicit setting still wins.
552
+ """
553
+ env = dict(os.environ)
554
+ if _IS_WINDOWS:
555
+ env.setdefault("PYTHONIOENCODING", "utf-8")
556
+ env.setdefault("PYTHONUTF8", "1")
557
+ return env
558
+
559
+
560
+ def _decode_output(data: Any) -> str:
561
+ """Decode captured child output (bytes) into text without mojibake.
562
+
563
+ Line endings are normalised to ``\\n``: reading bytes instead of using
564
+ ``text=True`` would otherwise let a Windows child's ``\\r\\n`` leak into
565
+ paginated output and break cursor stitching.
566
+ """
567
+ global _DECODING_CACHE
568
+ if data is None:
569
+ return ""
570
+ if isinstance(data, str):
571
+ return data
572
+ if not data:
573
+ return ""
574
+ if _DECODING_CACHE is None:
575
+ _DECODING_CACHE = _output_decodings()
576
+ for encoding in _DECODING_CACHE:
577
+ try:
578
+ text = data.decode(encoding)
579
+ except (UnicodeDecodeError, LookupError):
580
+ continue
581
+ return text.replace("\r\n", "\n").replace("\r", "\n")
582
+ return data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "\n")
583
+
584
+
290
585
  def _paginate(full: str, cursor: Any, max_chars: int) -> Tuple[str, bool, Optional[int]]:
291
586
  """Return ``(chunk, truncated, next_cursor)`` for a character offset cursor."""
292
587
  offset = max(0, _safe_int(cursor, 0))
@@ -457,6 +752,28 @@ def _spool_output(text: str) -> Optional[str]:
457
752
  # Tool 1-3: legacy tools (kept as-is for compatibility)
458
753
  # ---------------------------------------------------------------------------
459
754
 
755
+ def host_uptime_seconds() -> Optional[float]:
756
+ """Host uptime in seconds, or None when the platform cannot report it.
757
+
758
+ ``/proc/uptime`` only exists on Linux; Windows answers through
759
+ ``GetTickCount64`` (milliseconds since boot, immune to the 49-day wrap of the
760
+ 32-bit variant).
761
+ """
762
+ try:
763
+ with open("/proc/uptime", "r") as handle:
764
+ return float(handle.read().split()[0])
765
+ except Exception:
766
+ pass
767
+ if _IS_WINDOWS:
768
+ try:
769
+ import ctypes
770
+
771
+ return float(ctypes.windll.kernel32.GetTickCount64() / 1000.0)
772
+ except Exception:
773
+ return None
774
+ return None
775
+
776
+
460
777
  def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
461
778
  """Report that this node is reachable, with hard evidence.
462
779
 
@@ -468,12 +785,7 @@ def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
468
785
  import socket as _socket
469
786
 
470
787
  now = datetime.datetime.now().astimezone()
471
- uptime_s = None
472
- try:
473
- with open("/proc/uptime", "r") as handle:
474
- uptime_s = float(handle.read().split()[0])
475
- except Exception:
476
- pass
788
+ uptime_s = host_uptime_seconds()
477
789
  try:
478
790
  hostname = _socket.gethostname()
479
791
  except Exception:
@@ -498,9 +810,305 @@ def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
498
810
  if uptime_s is not None:
499
811
  payload["host_uptime_seconds"] = int(uptime_s)
500
812
  payload["host_uptime_human"] = "%dh %dm" % (int(uptime_s // 3600), int((uptime_s % 3600) // 60))
813
+ payload["node_version"] = version_module.__version__
814
+ # What kind of machine answered, and whether it is running on its own battery:
815
+ # the model should know it is talking to a phone on 12% before it starts a
816
+ # build, and this costs one cached block rather than a second tool call.
817
+ if _device is not None and _DEVICE_ENABLED is not False:
818
+ try:
819
+ blocks = _device.collect(sections=["device", "battery"], deadline=3.5)
820
+ device_block = blocks.get("device") or {}
821
+ battery = blocks.get("battery") or {}
822
+ payload["device_class"] = device_block.get("class", "")
823
+ payload["scenario"] = _device_scenario()
824
+ if device_block.get("model"):
825
+ payload["device_model"] = device_block.get("model")
826
+ if battery.get("available"):
827
+ payload["battery_percent"] = battery.get("percent")
828
+ payload["on_battery"] = (battery.get("plugged") == "battery"
829
+ or battery.get("ac_online") is False)
830
+ except Exception:
831
+ pass
832
+ try:
833
+ # Local state only: a status call must never wait on the network.
834
+ state = updater.status()
835
+ payload["update"] = {
836
+ "latest": state.get("latest") or "",
837
+ "update_available": state.get("update_available"),
838
+ "last_check": state.get("last_check") or "",
839
+ "last_applied": state.get("last_applied") or "",
840
+ "auto": state.get("auto"),
841
+ }
842
+ except Exception as exc: # diagnostics never break the answer
843
+ payload["update"] = {"error": str(exc)}
501
844
  return payload
502
845
 
503
846
 
847
+ def _parse_flag(value: Any, default: bool = False) -> bool:
848
+ if value is None:
849
+ return default
850
+ if isinstance(value, bool):
851
+ return value
852
+ return str(value).strip().lower() in ("1", "true", "yes", "on")
853
+
854
+
855
+ def _tool_mesh_update(args: Dict[str, Any]) -> Dict[str, Any]:
856
+ """Report, check for, or install a newer release of this node's own code.
857
+
858
+ ``status`` reads local state only; ``check`` asks GitHub; ``apply`` downloads
859
+ the release payload, verifies its published SHA-256, swaps it in with a
860
+ backup and restarts the agent (the tunnel drops for a few seconds). Applying
861
+ is refused in read-only mode, where every other mutation is refused too.
862
+ """
863
+ action = str(args.get("action") or "status").strip().lower()
864
+ force = _parse_flag(args.get("force"))
865
+ if action in ("status", "state", "info"):
866
+ return updater.status()
867
+ if action in ("check", "check_update", "latest"):
868
+ return updater.check_for_update(force=force, offline=_parse_flag(args.get("offline")))
869
+ if action in ("apply", "install", "update", "upgrade"):
870
+ if _READ_ONLY:
871
+ return {"error": "this node is read-only (MESH_READ_ONLY=1); "
872
+ "self-update is disabled"}
873
+ restart = args.get("restart")
874
+ return updater.apply_update(force=force,
875
+ restart=None if restart is None else _parse_flag(restart))
876
+ return {"error": "unknown action %r: use status, check or apply" % action}
877
+
878
+
879
+ # ---------------------------------------------------------------------------
880
+ # system_info: per-platform telemetry and wallpaper
881
+ # ---------------------------------------------------------------------------
882
+
883
+ def system_info_posix_commands() -> Dict[str, str]:
884
+ """The four telemetry blocks on Linux/macOS."""
885
+ return {
886
+ "disks": "df -h --output=target,size,used,avail,pcent 2>/dev/null | head -8",
887
+ "memory": "free -h 2>/dev/null | head -3",
888
+ "load": "uptime",
889
+ "top_processes": "ps -eo comm,%mem --sort=-%mem 2>/dev/null | head -6",
890
+ }
891
+
892
+
893
+ def system_info_windows_scripts() -> Dict[str, str]:
894
+ """The same four blocks as PowerShell scripts.
895
+
896
+ Windows has no ``df``/``free``/``uptime``/``ps``: the POSIX versions came
897
+ back as ``'df' is not recognized as an internal or external command`` and the
898
+ model read that as an error. These are read-only CIM queries. They are run
899
+ through PowerShell explicitly rather than through ``command_shell()``, so
900
+ telemetry keeps working when the node is configured with ``MESH_SHELL=cmd``
901
+ for the user's own commands.
902
+ """
903
+ size_gb = "($_.Size/1GB)"
904
+ free_gb = "($_.FreeSpace/1GB)"
905
+ return {
906
+ "disks": (
907
+ "Get-CimInstance Win32_LogicalDisk -Filter \"DriveType=3\" | ForEach-Object { "
908
+ "$pct = 0; if ($_.Size -gt 0) { $pct = 100 * $_.FreeSpace / $_.Size }; "
909
+ "'{0} {1:N1}G total, {2:N1}G free ({3:N0}% free)' -f "
910
+ f"$_.DeviceID, {size_gb}, {free_gb}, $pct "
911
+ "} | Out-String"
912
+ ),
913
+ "memory": (
914
+ "$os = Get-CimInstance Win32_OperatingSystem; "
915
+ "' total used free' + [Environment]::NewLine + "
916
+ "'Mem: {0,10:N1}G {1,10:N1}G {2,10:N1}G' -f "
917
+ "($os.TotalVisibleMemorySize/1MB), "
918
+ "(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory)/1MB), "
919
+ "($os.FreePhysicalMemory/1MB) | Out-String"
920
+ ),
921
+ "load": (
922
+ "$os = Get-CimInstance Win32_OperatingSystem; "
923
+ "$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; "
924
+ "'CPU load {0:N0}% ; booted {1:yyyy-MM-dd HH:mm} ; up {2:N1} h' -f "
925
+ "$cpu, $os.LastBootUpTime, (New-TimeSpan -Start $os.LastBootUpTime).TotalHours | Out-String"
926
+ ),
927
+ "top_processes": (
928
+ "Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 5 "
929
+ "@{n='COMMAND';e={$_.ProcessName}}, @{n='MEM_MB';e={[math]::Round($_.WorkingSet64/1MB,1)}} | "
930
+ "Format-Table -AutoSize | Out-String"
931
+ ),
932
+ }
933
+
934
+
935
+ def system_info_cmd_commands() -> Dict[str, str]:
936
+ """Last-resort cmd.exe telemetry, used only when PowerShell is absent.
937
+
938
+ ``wmic`` is deprecated and missing from the newest Windows builds, so this
939
+ path is a fallback rather than the default; ``tasklist`` always works.
940
+ """
941
+ return {
942
+ "disks": "wmic logicaldisk where drivetype=3 get DeviceID,Size,FreeSpace",
943
+ "memory": "wmic OS get FreePhysicalMemory,TotalVisibleMemorySize",
944
+ "load": "wmic cpu get loadpercentage",
945
+ "top_processes": "tasklist /fo table /nh",
946
+ }
947
+
948
+
949
+ def largest_wallpaper_image(path: str) -> str:
950
+ """Resolve a wallpaper path that may be a package directory.
951
+
952
+ KDE wallpaper packages hold one image per resolution and format (png, jpg,
953
+ webp, avif); Plasma shows the largest one, so that is what is reported.
954
+ """
955
+ if not path or not os.path.isdir(path):
956
+ return path
957
+ best, best_size = "", 0
958
+ for root, _dirs, files in os.walk(path):
959
+ for name in files:
960
+ if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
961
+ candidate = os.path.join(root, name)
962
+ try:
963
+ size = os.path.getsize(candidate)
964
+ except OSError:
965
+ continue
966
+ if size > best_size:
967
+ best, best_size = candidate, size
968
+ return best or path
969
+
970
+
971
+ def windows_wallpaper() -> str:
972
+ """Current wallpaper on Windows, read from the registry with ``winreg``.
973
+
974
+ No shell and no extra process: the path lives in
975
+ ``HKCU\\Control Panel\\Desktop\\WallPaper`` for a single image, while a
976
+ slideshow leaves that value empty and records the rotation in
977
+ ``...\\Explorer\\Wallpapers\\BackgroundHistoryPathList``. When the source file
978
+ is gone the desktop still shows the transcoded copy of it, which is what is
979
+ reported last.
980
+ """
981
+ try:
982
+ import winreg # Windows only; import guarded so this module loads anywhere
983
+ except Exception:
984
+ return ""
985
+ path = ""
986
+ try:
987
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Control Panel\Desktop") as key:
988
+ value, _kind = winreg.QueryValueEx(key, "WallPaper")
989
+ path = str(value or "").strip().strip('"')
990
+ except Exception:
991
+ path = ""
992
+ if not path:
993
+ try:
994
+ with winreg.OpenKey(
995
+ winreg.HKEY_CURRENT_USER,
996
+ r"Software\Microsoft\Windows\CurrentVersion\Explorer\Wallpapers",
997
+ ) as key:
998
+ history, _kind = winreg.QueryValueEx(key, "BackgroundHistoryPathList")
999
+ for candidate in reversed(list(history or [])):
1000
+ candidate = str(candidate).strip().strip('"')
1001
+ if candidate and os.path.isfile(candidate):
1002
+ path = candidate
1003
+ break
1004
+ except Exception:
1005
+ pass
1006
+ if not path:
1007
+ transcoded = os.path.join(
1008
+ os.environ.get("APPDATA", ""), "Microsoft", "Windows", "Themes", "TranscodedWallpaper")
1009
+ if os.path.isfile(transcoded):
1010
+ path = transcoded
1011
+ return os.path.expandvars(path) if path else ""
1012
+
1013
+
1014
+ def _device_scenario() -> str:
1015
+ """``termux`` | ``linux`` | ``windows`` | ``darwin`` - asked, never guessed.
1016
+
1017
+ ``core/device.py`` owns the decision (Windows first, then the Termux signals,
1018
+ then Darwin, else Linux) so that every tool, the telemetry and the tests all
1019
+ read the same answer. The fallback below exists only so a partial checkout (a
1020
+ device layer without the resolver) still classifies correctly instead of
1021
+ answering "unknown" and refusing every phone action.
1022
+ """
1023
+ if _device is None:
1024
+ return "unknown"
1025
+ resolver = getattr(_device, "scenario", None)
1026
+ try:
1027
+ if callable(resolver):
1028
+ return str(resolver())
1029
+ except Exception:
1030
+ pass
1031
+ try:
1032
+ if os.name == "nt":
1033
+ return "windows"
1034
+ if _device.is_termux():
1035
+ return "termux"
1036
+ import platform
1037
+ return "darwin" if platform.system() == "Darwin" else "linux"
1038
+ except Exception:
1039
+ return "unknown"
1040
+
1041
+
1042
+ def _device_timeout_hint(blocks: Any) -> Optional[str]:
1043
+ """One sentence when several phone sections timed out for the same reason.
1044
+
1045
+ A phone whose Termux:API app is missing, never opened, or frozen by battery
1046
+ optimisation makes EVERY ``termux-*`` call wait forever. The report would then
1047
+ be five identical "timed out" blocks and the model would have to infer the one
1048
+ root cause - or worse, tell the user the phone is broken. Naming the cause once
1049
+ is the difference between "the device does not answer" and "open the Termux:API
1050
+ app". Only per-command timeouts count (their reason names the command); a block
1051
+ that missed the overall deadline is a different story and is left alone.
1052
+ """
1053
+ if not isinstance(blocks, dict):
1054
+ return None
1055
+ timed_out: List[str] = []
1056
+ for name, block in blocks.items():
1057
+ if not isinstance(block, dict) or block.get("available") is not False:
1058
+ continue
1059
+ reason = str(block.get("reason") or "").lower()
1060
+ if "timed out" in reason and "termux-" in reason:
1061
+ timed_out.append(str(name))
1062
+ if len(timed_out) < 2:
1063
+ return None
1064
+ fix = ""
1065
+ for name in timed_out:
1066
+ candidate = blocks.get(name) or {}
1067
+ if isinstance(candidate, dict) and candidate.get("fix"):
1068
+ fix = str(candidate["fix"])
1069
+ break
1070
+ hint = ("%d sections timed out (%s). On an Android node that is usually one cause: "
1071
+ "the Termux:API app is not installed, has never been opened once, or is "
1072
+ "frozen by battery optimisation - every termux-api call then waits forever "
1073
+ "instead of failing."
1074
+ % (len(timed_out), ", ".join(sorted(timed_out))))
1075
+ if fix:
1076
+ hint += " Fix: " + fix
1077
+ return hint
1078
+
1079
+
1080
+ def _device_blocks(quick: Optional[bool] = None) -> Dict[str, Any]:
1081
+ """Device blocks for ``system_info``: battery, signal, time, cpu, cameras...
1082
+
1083
+ The whole collection runs under :data:`DEVICE_DEADLINE` in one thread pool, so
1084
+ the slowest source (a frozen Termux:API app, ``system_profiler``) cannot stall
1085
+ a status call, and a block that could not be read reports why instead of a
1086
+ fabricated zero.
1087
+ """
1088
+ if _device is None:
1089
+ return {"device": {"available": False,
1090
+ "reason": "core/device.py is missing from this checkout"}}
1091
+ if _DEVICE_ENABLED is False:
1092
+ return {"device": {"available": False,
1093
+ "reason": "device telemetry is switched off by the operator "
1094
+ "(MESH_DEVICE=0)"}}
1095
+ try:
1096
+ blocks = _device.collect(deadline=DEVICE_DEADLINE,
1097
+ quick=_DEVICE_QUICK if quick is None else bool(quick))
1098
+ except Exception as exc: # telemetry never breaks the summary
1099
+ return {"device": {"available": False,
1100
+ "reason": "device collection failed: %s: %s"
1101
+ % (type(exc).__name__, exc)}}
1102
+ if not isinstance(blocks, dict):
1103
+ return {}
1104
+ blocks = dict(blocks)
1105
+ blocks["scenario"] = _device_scenario()
1106
+ hint = _device_timeout_hint(blocks)
1107
+ if hint:
1108
+ blocks["device_hint"] = hint
1109
+ return blocks
1110
+
1111
+
504
1112
  def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
505
1113
  """A one-call summary of the host.
506
1114
 
@@ -512,13 +1120,27 @@ def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
512
1120
  import socket as _socket
513
1121
  import subprocess as _sp
514
1122
 
515
- def run(cmd: str) -> str:
1123
+ system = platform.system()
1124
+
1125
+ def run_argv(argv: List[str], timeout: float = 8.0) -> str:
516
1126
  try:
517
- out = _sp.run(cmd, shell=True, capture_output=True, text=True, timeout=8)
518
- return (out.stdout or out.stderr).strip()
1127
+ out = _sp.run(argv, capture_output=True, timeout=timeout,
1128
+ creationflags=_POPEN_FLAGS)
1129
+ # Disk labels, adapter names and localized CIM strings are not ASCII:
1130
+ # decode explicitly instead of trusting the locale.
1131
+ text = _decode_output(out.stdout) or _decode_output(out.stderr)
1132
+ return text.strip()
519
1133
  except Exception:
520
1134
  return ""
521
1135
 
1136
+ def run_shell(cmd: str) -> str:
1137
+ """Run one internal command in the node's own command shell."""
1138
+ return run_argv(command_shell()[1] + [cmd])
1139
+
1140
+ def run_powershell(script: str) -> str:
1141
+ argv = powershell_argv()
1142
+ return run_argv(argv + [script], timeout=12.0) if argv else ""
1143
+
522
1144
  info: Dict[str, Any] = {
523
1145
  "hostname": _socket.gethostname(),
524
1146
  "os": platform.platform(),
@@ -527,65 +1149,615 @@ def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
527
1149
  "user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
528
1150
  "home": os.path.expanduser("~"),
529
1151
  "cwd": os.getcwd(),
530
- "desktop": os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or "",
531
- "session_type": os.environ.get("XDG_SESSION_TYPE", ""),
532
- "shell": os.environ.get("SHELL", ""),
533
- # The shell bash_exec/run_job really use: on a Windows node without Git
534
- # Bash this is PowerShell, and commands must be written for it.
1152
+ "shell": os.environ.get("SHELL") or (os.environ.get("COMSPEC") if system == "Windows" else ""),
1153
+ # The shell bash_exec / run_job really use: PowerShell (or cmd.exe) on
1154
+ # Windows, never bash unless the operator asked for it, so the commands
1155
+ # the model writes must match this value.
535
1156
  "command_shell": command_shell()[0],
536
- "disks": run("df -h --output=target,size,used,avail,pcent 2>/dev/null | head -8"),
537
- "memory": run("free -h 2>/dev/null | head -3"),
538
- "load": run("uptime"),
539
- "top_processes": run("ps -eo comm,%mem --sort=-%mem 2>/dev/null | head -6"),
540
1157
  }
541
- # Current wallpaper, the usual first question for a desktop node.
542
- # Current wallpaper. KDE keeps the last applied value in the appletsrc as
543
- # `Image=<path-or-dir>` (sometimes with a file:// prefix); GNOME uses
544
- # gsettings. A directory means a wallpaper package, so resolve the image
545
- # inside it the same way Plasma does.
546
- wallpaper = ""
547
- kde_cfg = os.path.expanduser("~/.config/plasma-org.kde.plasma.desktop-appletsrc")
548
- if os.path.isfile(kde_cfg):
1158
+
1159
+ if system == "Windows":
1160
+ # Windows has no XDG_*: report the edition instead, and whether the
1161
+ # session is on the physical console or over RDP (SESSIONNAME is
1162
+ # "Console" or "RDP-Tcp#<n>").
549
1163
  try:
550
- with open(kde_cfg, "r", encoding="utf-8", errors="replace") as handle:
551
- for line in handle:
552
- if line.startswith("Image="):
553
- wallpaper = line.strip()[len("Image="):]
1164
+ edition = platform.win32_edition() # 3.8+, Windows only
554
1165
  except Exception:
555
- wallpaper = ""
556
- wallpaper = wallpaper.replace("file://", "")
557
- if not wallpaper:
558
- gnome = run("gsettings get org.gnome.desktop.background picture-uri 2>/dev/null")
559
- if gnome and "file://" in gnome:
560
- wallpaper = gnome.split("file://", 1)[1].strip().strip("'\"")
1166
+ edition = ""
1167
+ info["desktop"] = " ".join(
1168
+ part for part in ("Windows", platform.release(), edition) if part)
1169
+ info["session_type"] = (os.environ.get("SESSIONNAME") or "console").strip().lower()
1170
+ if powershell_argv():
1171
+ scripts = system_info_windows_scripts()
1172
+ info.update({name: run_powershell(script) for name, script in scripts.items()})
1173
+ else:
1174
+ # No PowerShell at all: cmd.exe still answers most of it.
1175
+ info.update({name: run_shell(cmd) for name, cmd in system_info_cmd_commands().items()})
1176
+ else:
1177
+ info["desktop"] = os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or ""
1178
+ info["session_type"] = os.environ.get("XDG_SESSION_TYPE", "")
1179
+ info.update({name: run_shell(cmd) for name, cmd in system_info_posix_commands().items()})
1180
+
1181
+ # Device telemetry (core/device.py): battery and charging state, network and
1182
+ # signal strength, locale, language, time, timezone, CPU, RAM, storage,
1183
+ # cameras, microphones and sensors. This is the half of the answer a phone user
1184
+ # asks for first, and on a laptop it is the battery - so it runs on every
1185
+ # platform, in one deadline, and each block says `available: false` + `reason`
1186
+ # when the platform has no source for it instead of reporting a fake zero.
1187
+ info.update(_device_blocks())
1188
+
1189
+ # Current wallpaper, the usual first question for a desktop node.
1190
+ wallpaper = windows_wallpaper() if system == "Windows" else ""
1191
+ if not wallpaper and system != "Windows":
1192
+ # KDE keeps the last applied value in the appletsrc as
1193
+ # `Image=<path-or-dir>` (sometimes with a file:// prefix); GNOME uses
1194
+ # gsettings. A directory means a wallpaper package, so resolve the image
1195
+ # inside it the same way Plasma does.
1196
+ kde_cfg = os.path.expanduser("~/.config/plasma-org.kde.plasma.desktop-appletsrc")
1197
+ if os.path.isfile(kde_cfg):
1198
+ try:
1199
+ with open(kde_cfg, "r", encoding="utf-8", errors="replace") as handle:
1200
+ for line in handle:
1201
+ if line.startswith("Image="):
1202
+ wallpaper = line.strip()[len("Image="):]
1203
+ except Exception:
1204
+ wallpaper = ""
1205
+ wallpaper = wallpaper.replace("file://", "")
1206
+ if not wallpaper:
1207
+ gnome = run_shell("gsettings get org.gnome.desktop.background picture-uri 2>/dev/null")
1208
+ if gnome and "file://" in gnome:
1209
+ wallpaper = gnome.split("file://", 1)[1].strip().strip("'\"")
561
1210
  if wallpaper:
562
- if os.path.isdir(wallpaper):
563
- # A wallpaper package holds many resolutions and formats (png, jpg,
564
- # webp, avif); pick the largest image inside it.
565
- best, best_size = "", 0
566
- for root, _dirs, files in os.walk(wallpaper):
567
- for name in files:
568
- if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
569
- path = os.path.join(root, name)
570
- try:
571
- size = os.path.getsize(path)
572
- except OSError:
573
- continue
574
- if size > best_size:
575
- best, best_size = path, size
576
- wallpaper = best or wallpaper
1211
+ wallpaper = largest_wallpaper_image(wallpaper)
577
1212
  info["wallpaper"] = wallpaper
578
1213
  info["wallpaper_exists"] = os.path.isfile(wallpaper)
579
1214
  return info
580
1215
 
581
1216
 
582
1217
  def _tool_system_vitals(args: Dict[str, Any]) -> Any:
1218
+ vitals = None
583
1219
  try:
584
1220
  if _core_get_host_vitals is not None:
585
- return _core_get_host_vitals()
1221
+ vitals = _core_get_host_vitals()
586
1222
  except Exception:
587
- pass
588
- return _fallback_host_vitals()
1223
+ vitals = None
1224
+ if vitals is None:
1225
+ vitals = _fallback_host_vitals()
1226
+ # A laptop's most important "vital" is its battery, and a phone's is battery
1227
+ # plus temperature: both come from the same cached device block that
1228
+ # system_info already uses, so asking here costs nothing extra.
1229
+ if _device is not None and _DEVICE_ENABLED is not False and isinstance(vitals, dict):
1230
+ try:
1231
+ blocks = _device.collect(sections=["battery", "hardware"], deadline=4.0)
1232
+ except Exception:
1233
+ blocks = {}
1234
+ battery = blocks.get("battery") or {}
1235
+ hardware = blocks.get("hardware") or {}
1236
+ if battery:
1237
+ vitals["battery"] = battery
1238
+ thermal = hardware.get("thermal") or []
1239
+ if thermal:
1240
+ vitals["thermal"] = thermal
1241
+ return vitals
1242
+
1243
+
1244
+ # ---------------------------------------------------------------------------
1245
+ # Device branch: report and control the device the node itself runs on
1246
+ # ---------------------------------------------------------------------------
1247
+ # One rule shapes this whole section: the tool surface is advertised by the
1248
+ # gateway for EVERY node (it holds a static copy that must match this file), so a
1249
+ # phone-only tool cannot disappear on a desktop. What differs per platform is the
1250
+ # ANSWER - `available: false` plus the reason and, where one exists, the fix.
1251
+ #
1252
+ # The three action tools execute nothing but `termux-*` argv lists through
1253
+ # core/termux.py: no shell, no command assembled from model input, a hard timeout
1254
+ # per call, and every call guarded by the operator's switches (MESH_DEVICE,
1255
+ # MESH_DEVICE_ACTIONS, MESH_DEVICE_PIM, MESH_READ_ONLY).
1256
+
1257
+ #: The actions each tool accepts. Kept as tuples next to the code that implements
1258
+ #: them, and reused for the allowlist check and the "unknown action" message.
1259
+ DEVICE_CONTROL_ACTIONS: Tuple[str, ...] = (
1260
+ "torch", "vibrate", "volume", "volume_get", "brightness", "tts_speak", "toast",
1261
+ "notify", "notify_list", "notify_remove", "clipboard_get", "clipboard_set",
1262
+ "media", "media_scan", "wakelock", "download", "open", "share", "dialog", "wallpaper",
1263
+ )
1264
+ DEVICE_CAPTURE_ACTIONS: Tuple[str, ...] = (
1265
+ "camera_list", "camera_photo", "mic_record_start", "mic_record_stop",
1266
+ "mic_record_status", "location", "fingerprint", "usb_list", "usb_access",
1267
+ "infrared_frequencies", "infrared_transmit",
1268
+ )
1269
+ DEVICE_MESSAGES_ACTIONS: Tuple[str, ...] = (
1270
+ "sms_list", "sms_send", "call_log", "contacts", "call",
1271
+ )
1272
+
1273
+ #: Audio streams termux-volume understands.
1274
+ _DEVICE_STREAMS = ("music", "call", "alarm", "notification", "ring", "system")
1275
+
1276
+
1277
+ def _device_branch_error() -> Optional[Dict[str, Any]]:
1278
+ """Why the device branch cannot answer at all, or ``None`` when it can."""
1279
+ if _device is None:
1280
+ return {"available": False,
1281
+ "reason": "the device layer (core/device.py) is missing from this checkout"}
1282
+ if _DEVICE_ENABLED is False:
1283
+ return {"available": False,
1284
+ "reason": "the device tools are switched off by the operator (MESH_DEVICE=0)",
1285
+ "fix": "set MESH_DEVICE=auto (or 1) in agent.env and restart the node"}
1286
+ return None
1287
+
1288
+
1289
+ def _action_allowed(action: str) -> Optional[Dict[str, Any]]:
1290
+ """Apply the optional ``MESH_DEVICE_ACTIONS`` allowlist."""
1291
+ if _DEVICE_ACTIONS is None:
1292
+ return None
1293
+ if action in _DEVICE_ACTIONS:
1294
+ return None
1295
+ return {"available": False, "action": action,
1296
+ "reason": "the operator restricted the device tools to: %s"
1297
+ % ", ".join(_DEVICE_ACTIONS),
1298
+ "fix": "add this action to MESH_DEVICE_ACTIONS in agent.env, or clear it"}
1299
+
1300
+
1301
+ def _android_only(action: str) -> Optional[Dict[str, Any]]:
1302
+ """A phone action on a laptop is an explanation, not a failure."""
1303
+ scenario = _device_scenario()
1304
+ if scenario == "termux":
1305
+ return None
1306
+ return {"available": False, "action": action, "scenario": scenario,
1307
+ "reason": ("this action drives an Android phone through the Termux:API, "
1308
+ "and this node runs on '%s'" % scenario),
1309
+ "fix": ("run the node on the phone itself (docs/TERMUX.md); on a laptop or "
1310
+ "desktop use device_info for telemetry instead")}
1311
+
1312
+
1313
+ def _read_only_refusal(action: str) -> Optional[Dict[str, Any]]:
1314
+ if not _READ_ONLY:
1315
+ return None
1316
+ return {"available": False, "action": action,
1317
+ "reason": ("this node is read-only (MESH_READ_ONLY=1): only observation is "
1318
+ "allowed, and this action changes something on the device")}
1319
+
1320
+
1321
+ def _termux_action(action: str, command: str, argv: Sequence[Any] = (),
1322
+ timeout: float = 10.0, parse: bool = True) -> Dict[str, Any]:
1323
+ """Run one ``termux-*`` argv through the adapter and shape the answer.
1324
+
1325
+ Every guard is applied here rather than in each action handler, so a new action
1326
+ cannot accidentally skip the allowlist, the platform check or the timeout.
1327
+ """
1328
+ for guard in (_device_branch_error(), _action_allowed(action), _android_only(action)):
1329
+ if guard:
1330
+ guard = dict(guard)
1331
+ guard.setdefault("action", action)
1332
+ guard.setdefault("command", command)
1333
+ return guard
1334
+ adapter = _termux
1335
+ if adapter is None:
1336
+ return {"available": False, "action": action, "command": command,
1337
+ "reason": "core/termux.py is missing from this checkout"}
1338
+ argv = [str(item) for item in argv]
1339
+ try:
1340
+ runner = adapter.run_json if parse else adapter.run_text
1341
+ result = runner(command, argv, timeout)
1342
+ except Exception as exc:
1343
+ return {"ok": False, "action": action, "command": command, "argv": argv,
1344
+ "reason": "%s: %s" % (type(exc).__name__, exc)}
1345
+ payload: Dict[str, Any] = {"action": action, "command": command, "argv": argv}
1346
+ if result.get("ok"):
1347
+ payload["ok"] = True
1348
+ payload["result"] = result.get("value")
1349
+ if result.get("parsed") is False:
1350
+ payload["note"] = "the command answered plain text, not JSON"
1351
+ if result.get("duration_ms") is not None:
1352
+ payload["duration_ms"] = result.get("duration_ms")
1353
+ return payload
1354
+ payload["ok"] = False
1355
+ payload["reason"] = result.get("reason") or "the command did not answer"
1356
+ for key in ("fix", "missing", "denied", "timeout", "raw"):
1357
+ if result.get(key):
1358
+ payload[key] = result[key]
1359
+ return payload
1360
+
1361
+
1362
+ def _require_arg(args: Dict[str, Any], name: str, action: str) -> Any:
1363
+ """Return the argument or a shaped "missing argument" answer (never raises)."""
1364
+ value = args.get(name)
1365
+ if value is None or (isinstance(value, str) and not value.strip()):
1366
+ return None
1367
+ return value
1368
+
1369
+
1370
+ def _missing_arg(action: str, name: str, wants: str) -> Dict[str, Any]:
1371
+ return {"error": "action %r needs %s: %s" % (action, name, wants)}
1372
+
1373
+
1374
+ def _capture_path(args: Dict[str, Any], default_name: str) -> str:
1375
+ """Where a capture is written: the caller's path, else the capture directory.
1376
+
1377
+ The directory is created here (not by the tool) so the answer can report the
1378
+ real path and the model never has to guess whether the write worked.
1379
+ """
1380
+ raw = args.get("path")
1381
+ if isinstance(raw, str) and raw.strip():
1382
+ target = _resolve(raw.strip())
1383
+ else:
1384
+ base = _device.capture_dir() if _device is not None else os.path.expanduser("~")
1385
+ target = os.path.join(base, default_name)
1386
+ parent = os.path.dirname(target)
1387
+ if parent:
1388
+ try:
1389
+ os.makedirs(parent, exist_ok=True)
1390
+ except Exception:
1391
+ pass
1392
+ return target
1393
+
1394
+
1395
+ def _file_note(path: str) -> Dict[str, Any]:
1396
+ """Size and sha256 of a file the node just produced, when it exists."""
1397
+ try:
1398
+ return {"path": path, "bytes": os.path.getsize(path)}
1399
+ except Exception:
1400
+ return {"path": path}
1401
+
1402
+
1403
+ def _tool_device_info(args: Dict[str, Any]) -> Dict[str, Any]:
1404
+ """Full device report: every block, or one of them, on any platform."""
1405
+ guard = _device_branch_error()
1406
+ if guard:
1407
+ return guard
1408
+ section = str(args.get("section") or "all").strip().lower()
1409
+ known = tuple(_device.ALL_SECTIONS)
1410
+ if section not in ("summary", "all") and section not in known:
1411
+ return {"error": "unknown section %r: use %s, summary or all"
1412
+ % (section, ", ".join(known))}
1413
+ quick = _parse_flag(args.get("quick"))
1414
+ fresh = _parse_flag(args.get("fresh"))
1415
+ if section == "summary":
1416
+ try:
1417
+ return _device.summary(quick=quick)
1418
+ except Exception as exc:
1419
+ return {"error": "device summary failed: %s: %s" % (type(exc).__name__, exc)}
1420
+ sensor = args.get("sensor")
1421
+ sections = ["all"] if section == "all" else [section]
1422
+ try:
1423
+ blocks = _device.collect(sections=sections, fresh=fresh, quick=quick,
1424
+ sensor=str(sensor) if sensor else None)
1425
+ except Exception as exc:
1426
+ return {"error": "device collection failed: %s: %s" % (type(exc).__name__, exc)}
1427
+ device_block = blocks.get("device") or {}
1428
+ result = {
1429
+ "scenario": _device_scenario(),
1430
+ "device_class": device_block.get("class", ""),
1431
+ "sections": blocks,
1432
+ }
1433
+ hint = _device_timeout_hint(blocks)
1434
+ if hint:
1435
+ result["hint"] = hint
1436
+ return result
1437
+
1438
+
1439
+ def _tool_device_control(args: Dict[str, Any]) -> Dict[str, Any]:
1440
+ """Reversible device actions (torch, volume, clipboard, notifications...)."""
1441
+ guard = _device_branch_error()
1442
+ if guard:
1443
+ return guard
1444
+ action = str(args.get("action") or "").strip().lower()
1445
+ if action not in DEVICE_CONTROL_ACTIONS:
1446
+ return {"error": "unknown action %r: use %s"
1447
+ % (action, ", ".join(DEVICE_CONTROL_ACTIONS))}
1448
+ guard = _read_only_refusal(action) or _action_allowed(action)
1449
+ if guard:
1450
+ return guard
1451
+
1452
+ if action == "torch":
1453
+ state = "on" if _parse_flag(args.get("on")) else "off"
1454
+ return _termux_action(action, "termux-torch", [state], timeout=8.0)
1455
+ if action == "vibrate":
1456
+ duration = _clamp_int(args.get("value"), 1, 60000, 500)
1457
+ return _termux_action(action, "termux-vibrate", ["-d", duration], timeout=8.0)
1458
+ if action == "volume":
1459
+ value = args.get("value")
1460
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
1461
+ return _missing_arg(action, "value", "a level from 0 to 15")
1462
+ stream = str(args.get("stream") or "music").strip().lower()
1463
+ if stream not in _DEVICE_STREAMS:
1464
+ return {"error": "stream must be one of: %s" % ", ".join(_DEVICE_STREAMS)}
1465
+ level = _clamp_int(value, 0, 15, 0)
1466
+ return _termux_action(action, "termux-volume", ["-s", stream, level], timeout=8.0)
1467
+ if action == "volume_get":
1468
+ return _termux_action(action, "termux-volume", [], timeout=8.0)
1469
+ if action == "brightness":
1470
+ value = args.get("value")
1471
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
1472
+ return _missing_arg(action, "value", "a brightness from 0 to 255")
1473
+ return _termux_action(action, "termux-brightness", [_clamp_int(value, 0, 255, 128)],
1474
+ timeout=8.0)
1475
+ if action in ("tts_speak", "toast"):
1476
+ text = _require_arg(args, "text", action)
1477
+ if text is None:
1478
+ return _missing_arg(action, "text", "the words to speak or show")
1479
+ command = "termux-tts-speak" if action == "tts_speak" else "termux-toast"
1480
+ return _termux_action(action, command, [str(text)], timeout=15.0)
1481
+ if action == "notify":
1482
+ body = _require_arg(args, "text", action)
1483
+ if body is None:
1484
+ return _missing_arg(action, "text", "the notification body")
1485
+ argv: List[Any] = ["--title", str(args.get("title") or "Antigravity Mesh"),
1486
+ "--content", str(body)]
1487
+ if args.get("id") is not None:
1488
+ argv += ["--id", str(args.get("id"))]
1489
+ return _termux_action(action, "termux-notification", argv, timeout=10.0)
1490
+ if action == "notify_list":
1491
+ return _termux_action(action, "termux-notification-list", [], timeout=10.0)
1492
+ if action == "notify_remove":
1493
+ notification_id = _require_arg(args, "id", action)
1494
+ if notification_id is None:
1495
+ return _missing_arg(action, "id", "the id used when the notification was posted")
1496
+ return _termux_action(action, "termux-notification-remove", [notification_id],
1497
+ timeout=8.0)
1498
+ if action == "clipboard_get":
1499
+ return _termux_action(action, "termux-clipboard-get", [], timeout=8.0, parse=False)
1500
+ if action == "clipboard_set":
1501
+ text = _require_arg(args, "text", action)
1502
+ if text is None:
1503
+ return _missing_arg(action, "text", "the text to put on the clipboard")
1504
+ # The text travels as one argv element: it is never interpreted by a shell.
1505
+ return _termux_action(action, "termux-clipboard-set", [str(text)], timeout=8.0,
1506
+ parse=False)
1507
+ if action == "media":
1508
+ mode = str(args.get("text") or args.get("state") or "info").strip().lower()
1509
+ if mode not in ("play", "pause", "stop", "info"):
1510
+ return {"error": "media: pass text=play|pause|stop|info (default info), and "
1511
+ "path=<file> for play"}
1512
+ argv = [mode]
1513
+ if mode == "play":
1514
+ path = _require_arg(args, "path", action)
1515
+ if path is None:
1516
+ return _missing_arg(action, "path", "the media file to play")
1517
+ argv.append(str(path))
1518
+ return _termux_action(action, "termux-media-player", argv, timeout=10.0)
1519
+ if action == "media_scan":
1520
+ path = _require_arg(args, "path", action)
1521
+ if path is None:
1522
+ return _missing_arg(action, "path", "the file to announce to the media scanner")
1523
+ return _termux_action(action, "termux-media-scan", [str(path)], timeout=15.0)
1524
+ if action == "wakelock":
1525
+ state = str(args.get("state") or "status").strip().lower()
1526
+ if state not in ("acquire", "release", "status"):
1527
+ return {"error": "wakelock: state must be acquire, release or status"}
1528
+ # Same explanation as every other phone action: a wake lock on a laptop is
1529
+ # meaningless, and "this node is not a phone" is the honest answer rather
1530
+ # than an adapter-level "command not found".
1531
+ guard = _android_only(action)
1532
+ if guard:
1533
+ guard = dict(guard)
1534
+ guard["command"] = "termux-wake-lock"
1535
+ return guard
1536
+ if _termux is None:
1537
+ return {"available": False, "action": action,
1538
+ "reason": "core/termux.py is missing from this checkout"}
1539
+ try:
1540
+ result = _termux.wake_lock(state)
1541
+ except Exception as exc:
1542
+ return {"ok": False, "action": action,
1543
+ "reason": "%s: %s" % (type(exc).__name__, exc)}
1544
+ payload = dict(result) if isinstance(result, dict) else {"result": result}
1545
+ payload["action"] = action
1546
+ payload["note"] = ("the wake lock keeps Android from freezing background work "
1547
+ "while the screen is off; it is ref-counted and released when "
1548
+ "the last job finishes")
1549
+ return payload
1550
+ if action == "download":
1551
+ url = _require_arg(args, "url", action)
1552
+ if url is None:
1553
+ return _missing_arg(action, "url", "the URL for the system download manager")
1554
+ return _termux_action(action, "termux-download", [str(url)], timeout=15.0)
1555
+ if action in ("open", "share"):
1556
+ path = _require_arg(args, "path", action)
1557
+ url = _require_arg(args, "url", action)
1558
+ if action == "open":
1559
+ if path is None and url is None:
1560
+ return _missing_arg(action, "path or url", "what to open")
1561
+ if path is None:
1562
+ return _termux_action(action, "termux-open-url", [str(url)], timeout=10.0)
1563
+ return _termux_action(action, "termux-open", [str(path)], timeout=10.0)
1564
+ if path is None:
1565
+ return _missing_arg(action, "path", "the file to hand to Android's share sheet")
1566
+ return _termux_action(action, "termux-share", [str(path)], timeout=20.0)
1567
+ if action == "dialog":
1568
+ # The dialog blocks until the user answers on the phone, so it carries its
1569
+ # own timeout and the result says when nothing came back in time.
1570
+ seconds = _clamp_int(args.get("timeout_sec"), 5, 120, 30)
1571
+ argv = ["text", "-t", str(args.get("title") or "Antigravity Mesh"),
1572
+ "-i", str(args.get("text") or "")]
1573
+ result = _termux_action(action, "termux-dialog", argv, timeout=float(seconds) + 5.0)
1574
+ result["note"] = ("a dialog waits for the person holding the phone; nothing is "
1575
+ "returned until they answer or the %ds wait expires" % seconds)
1576
+ return result
1577
+ if action == "wallpaper":
1578
+ path = _require_arg(args, "path", action)
1579
+ url = _require_arg(args, "url", action)
1580
+ if path is None and url is None:
1581
+ return _missing_arg(action, "path or url", "the image to set as wallpaper")
1582
+ if path is None:
1583
+ return _termux_action(action, "termux-wallpaper", ["-u", str(url)], timeout=20.0)
1584
+ return _termux_action(action, "termux-wallpaper", ["-f", str(path)], timeout=20.0)
1585
+ return {"error": "unknown action %r" % action} # unreachable, kept total
1586
+
1587
+
1588
+ def _tool_device_capture(args: Dict[str, Any]) -> Dict[str, Any]:
1589
+ """Camera, microphone, location, fingerprint, USB and infrared."""
1590
+ guard = _device_branch_error()
1591
+ if guard:
1592
+ return guard
1593
+ action = str(args.get("action") or "").strip().lower()
1594
+ if action not in DEVICE_CAPTURE_ACTIONS:
1595
+ return {"error": "unknown action %r: use %s"
1596
+ % (action, ", ".join(DEVICE_CAPTURE_ACTIONS))}
1597
+ # Even the listing actions are gated: they enumerate the physical sensors of a
1598
+ # device the operator may not be holding right now.
1599
+ guard = _read_only_refusal(action) or _action_allowed(action)
1600
+ if guard:
1601
+ return guard
1602
+
1603
+ if action == "camera_list":
1604
+ return _termux_action(action, "termux-camera-info", [], timeout=8.0)
1605
+ if action == "camera_photo":
1606
+ camera_id = _clamp_int(args.get("camera_id"), 0, 9, 0)
1607
+ path = _capture_path(args, "photo-%s.jpg" % time.strftime("%Y%m%d-%H%M%S"))
1608
+ result = _termux_action(action, "termux-camera-photo",
1609
+ ["-c", camera_id, path], timeout=30.0)
1610
+ if result.get("ok"):
1611
+ result.update(_file_note(path))
1612
+ result["note"] = ("the file stays on the node; share_file publishes it "
1613
+ "deliberately, if and when the operator asks")
1614
+ else:
1615
+ # The generated name is the only handle the model has on a failed
1616
+ # attempt - it is what a retry must avoid clobbering and what a human
1617
+ # looks for on the phone. An adapter failure (missing Termux:API, denied
1618
+ # permission, timeout) carries no "available" key, so this must be a
1619
+ # plain else, not a check for the guard shape.
1620
+ result["path"] = path
1621
+ return result
1622
+ if action in ("mic_record_start", "mic_record_stop", "mic_record_status"):
1623
+ path = _capture_path(args, "recording-%s.m4a" % time.strftime("%Y%m%d-%H%M%S"))
1624
+ if action == "mic_record_status":
1625
+ return _termux_action(action, "termux-microphone-record", ["-i"], timeout=8.0)
1626
+ if action == "mic_record_stop":
1627
+ return _termux_action(action, "termux-microphone-record", ["-q"], timeout=8.0)
1628
+ argv: List[Any] = ["-f", path]
1629
+ seconds = args.get("seconds")
1630
+ if isinstance(seconds, (int, float)) and not isinstance(seconds, bool):
1631
+ argv += ["-l", _clamp_int(seconds, 1, 3600, 60)]
1632
+ result = _termux_action(action, "termux-microphone-record", argv, timeout=15.0)
1633
+ if result.get("ok"):
1634
+ result.update(_file_note(path))
1635
+ result["note"] = ("recording runs in the Termux:API app: stop it with "
1636
+ "action=mic_record_stop, and keep the app in the foreground "
1637
+ "on Android 11+")
1638
+ return result
1639
+ if action == "location":
1640
+ provider = str(args.get("provider") or "gps").strip().lower()
1641
+ if provider not in ("gps", "network", "passive"):
1642
+ return {"error": "provider must be gps, network or passive"}
1643
+ result = _termux_action(action, "termux-location", ["-p", provider], timeout=25.0)
1644
+ if result.get("ok"):
1645
+ result["note"] = "location comes from Android; it is as precise as the grant allows"
1646
+ return result
1647
+ if action == "fingerprint":
1648
+ result = _termux_action(action, "termux-fingerprint", [], timeout=60.0)
1649
+ result["note"] = ("only the verdict is returned - no fingerprint data ever reaches "
1650
+ "this node or the gateway")
1651
+ return result
1652
+ if action == "usb_list":
1653
+ return _termux_action(action, "termux-usb", ["-l"], timeout=10.0)
1654
+ if action == "usb_access":
1655
+ path = _require_arg(args, "path", action)
1656
+ if path is None:
1657
+ return _missing_arg(action, "path", "the device path printed by usb_list")
1658
+ # Only the permission request is exposed: `termux-usb -e <program>` would let
1659
+ # a model argument name an arbitrary program to execute, which this node
1660
+ # never does. Real I/O goes through the returned descriptor in a script the
1661
+ # operator writes and runs deliberately.
1662
+ result = _termux_action(action, "termux-usb", ["-r", str(path)], timeout=30.0)
1663
+ result["note"] = ("this asks Android for permission and returns the file "
1664
+ "descriptor; reading and writing the device is deliberately left "
1665
+ "to a script the operator runs themselves")
1666
+ return result
1667
+ if action == "infrared_frequencies":
1668
+ return _termux_action(action, "termux-infrared-frequencies", [], timeout=8.0)
1669
+ if action == "infrared_transmit":
1670
+ frequency = args.get("frequency")
1671
+ pattern = _require_arg(args, "pattern", action)
1672
+ if not isinstance(frequency, (int, float)) or isinstance(frequency, bool):
1673
+ return _missing_arg(action, "frequency", "the carrier frequency in Hz")
1674
+ if pattern is None:
1675
+ return _missing_arg(action, "pattern", "durations in microseconds, e.g. 1000,2000")
1676
+ # Only the whitespace AROUND a separator is forgiven ("1000, 2000"): the
1677
+ # durations themselves are one number each, so stripping every space would
1678
+ # silently turn "1000 2000" into the single pulse "10002000".
1679
+ cleaned = re.sub(r"\s*,\s*", ",", str(pattern).strip())
1680
+ if not re.match(r"^\d+(,\d+)*$", cleaned):
1681
+ return {"error": "pattern must be comma-separated integers (microseconds), "
1682
+ "e.g. 1000,2000"}
1683
+ return _termux_action(action, "termux-infrared-transmit",
1684
+ ["-f", _clamp_int(frequency, 1, 1000000, 38000), cleaned],
1685
+ timeout=10.0)
1686
+ return {"error": "unknown action %r" % action} # unreachable, kept total
1687
+
1688
+
1689
+ def _tool_device_messages(args: Dict[str, Any]) -> Dict[str, Any]:
1690
+ """SMS, call log, contacts and placing a call - private data, opt-in."""
1691
+ guard = _device_branch_error()
1692
+ if guard:
1693
+ return guard
1694
+ action = str(args.get("action") or "").strip().lower()
1695
+ if action not in DEVICE_MESSAGES_ACTIONS:
1696
+ return {"error": "unknown action %r: use %s"
1697
+ % (action, ", ".join(DEVICE_MESSAGES_ACTIONS))}
1698
+ guard = _action_allowed(action)
1699
+ if guard:
1700
+ return guard
1701
+ if not _DEVICE_PIM:
1702
+ return {"available": False, "action": action,
1703
+ "reason": ("messages, contacts and the call log are the operator's private "
1704
+ "data, so they are off until the operator enables them on the node"),
1705
+ "fix": ("set MESH_DEVICE_PIM=1 in agent.env and restart the node; grant "
1706
+ "Termux:API the SMS, call-log and contacts permissions in Android "
1707
+ "settings")}
1708
+ if action in ("sms_send", "call"):
1709
+ guard = _read_only_refusal(action)
1710
+ if guard:
1711
+ return guard
1712
+
1713
+ if action == "sms_list":
1714
+ limit = _clamp_int(args.get("limit"), 1, 50, 10)
1715
+ argv: List[Any] = ["-l", limit]
1716
+ if args.get("offset") is not None:
1717
+ argv += ["-o", _clamp_int(args.get("offset"), 0, 10000, 0)]
1718
+ if args.get("type"):
1719
+ argv += ["-t", str(args.get("type"))]
1720
+ return _termux_action(action, "termux-sms-list", argv, timeout=15.0)
1721
+ if action == "sms_send":
1722
+ number = _require_arg(args, "number", action)
1723
+ text = _require_arg(args, "text", action)
1724
+ if number is None:
1725
+ return _missing_arg(action, "number", "the recipient number")
1726
+ if text is None:
1727
+ return _missing_arg(action, "text", "the message body")
1728
+ return _termux_action(action, "termux-sms-send", ["-n", str(number), str(text)],
1729
+ timeout=20.0)
1730
+ if action == "call_log":
1731
+ limit = _clamp_int(args.get("limit"), 1, 50, 10)
1732
+ argv = ["-l", limit]
1733
+ if args.get("offset") is not None:
1734
+ argv += ["-o", _clamp_int(args.get("offset"), 0, 10000, 0)]
1735
+ if args.get("type"):
1736
+ argv += ["-t", str(args.get("type"))]
1737
+ return _termux_action(action, "termux-call-log", argv, timeout=15.0)
1738
+ if action == "contacts":
1739
+ # termux-contact-list has no filter of its own, so the query is applied here
1740
+ # and the (large) answer never has to travel to the client whole.
1741
+ result = _termux_action(action, "termux-contact-list", [], timeout=20.0)
1742
+ if not result.get("ok"):
1743
+ return result
1744
+ limit = _clamp_int(args.get("limit"), 1, 50, 10)
1745
+ query = str(args.get("query") or "").strip().lower()
1746
+ contacts = result.get("result")
1747
+ if not isinstance(contacts, list):
1748
+ return result
1749
+ if query:
1750
+ contacts = [c for c in contacts if isinstance(c, dict)
1751
+ and query in json.dumps(c, ensure_ascii=False).lower()]
1752
+ result["result"] = contacts[:limit]
1753
+ result["total_matches"] = len(contacts)
1754
+ return result
1755
+ if action == "call":
1756
+ number = _require_arg(args, "number", action)
1757
+ if number is None:
1758
+ return _missing_arg(action, "number", "the number to dial")
1759
+ return _termux_action(action, "termux-telephony-call", [str(number)], timeout=15.0)
1760
+ return {"error": "unknown action %r" % action} # unreachable, kept total
589
1761
 
590
1762
 
591
1763
  def _tool_get_orchestration_skill(args: Dict[str, Any]) -> Any:
@@ -633,33 +1805,42 @@ def _tool_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
633
1805
  def _run_bash(command: str, timeout: int) -> Tuple[str, str, int, float]:
634
1806
  started = time.time()
635
1807
  try:
1808
+ # Bytes, not text: the child's encoding is discovered with
1809
+ # _decode_output() instead of assumed from the locale (see above).
636
1810
  proc = subprocess.run(
637
1811
  _shell_argv(command),
638
1812
  cwd=_WORKSPACE if os.path.isdir(_WORKSPACE) else None,
639
1813
  stdin=subprocess.DEVNULL,
640
1814
  capture_output=True,
641
- text=True,
642
1815
  timeout=timeout,
643
- errors="replace",
644
1816
  creationflags=_POPEN_FLAGS,
1817
+ env=_child_env(),
1818
+ )
1819
+ return (
1820
+ _decode_output(proc.stdout),
1821
+ _decode_output(proc.stderr),
1822
+ proc.returncode,
1823
+ round(time.time() - started, 3),
645
1824
  )
646
- return proc.stdout or "", proc.stderr or "", proc.returncode, round(time.time() - started, 3)
647
1825
  except subprocess.TimeoutExpired as exc:
648
- out = exc.stdout or ""
649
- err = exc.stderr or ""
650
- if isinstance(out, bytes):
651
- out = out.decode("utf-8", "replace")
652
- if isinstance(err, bytes):
653
- err = err.decode("utf-8", "replace")
1826
+ out = _decode_output(exc.stdout)
1827
+ err = _decode_output(exc.stderr)
654
1828
  err = (err + "\n" if err else "") + (
655
- "Command timed out after %d seconds. Tip: for long background tasks use "
656
- "'nohup ... > output.log 2>&1 &' or run_job." % timeout
1829
+ "Command timed out after %d seconds. Tip: for long tasks start them with "
1830
+ "run_job instead of bash_exec.%s" % (timeout, _background_hint())
657
1831
  )
658
1832
  return out, err, 124, round(time.time() - started, 3)
659
1833
  except Exception as exc:
660
1834
  return "", str(exc), 1, round(time.time() - started, 3)
661
1835
 
662
1836
 
1837
+ def _background_hint() -> str:
1838
+ """The shell-correct way to leave something running on this host."""
1839
+ if _IS_WINDOWS:
1840
+ return " There is no '&' background operator in PowerShell or cmd."
1841
+ return " On POSIX: 'nohup <command> > output.log 2>&1 &'."
1842
+
1843
+
663
1844
  def _tool_bash_exec(args: Dict[str, Any]) -> Dict[str, Any]:
664
1845
  command = args.get("command")
665
1846
  if not isinstance(command, str) or not command.strip():
@@ -787,7 +1968,34 @@ def _tool_write_file(args: Dict[str, Any]) -> Dict[str, Any]:
787
1968
 
788
1969
  data = content.encode("utf-8", "replace")
789
1970
  _atomic_write_bytes(target, data, mode=mode)
790
- return {"ok": True, "path": target, "bytes": len(data), "sha256": _sha256_hex(data)}
1971
+ result: Dict[str, Any] = {
1972
+ "ok": True,
1973
+ "path": target,
1974
+ "bytes": len(data),
1975
+ "sha256": _sha256_hex(data),
1976
+ }
1977
+ if mode is not None:
1978
+ # Report what the filesystem actually kept. On Windows a 0755 request
1979
+ # silently becomes "read-write, no execute bit" because the platform
1980
+ # has no POSIX mode bits — claiming success would make the model
1981
+ # believe it produced an executable script.
1982
+ effective: Optional[int] = None
1983
+ try:
1984
+ effective = os.stat(target).st_mode & 0o777
1985
+ except OSError:
1986
+ pass
1987
+ result["mode"] = ("0%o" % effective) if effective is not None else None
1988
+ if _IS_WINDOWS:
1989
+ result["mode_applied"] = False
1990
+ result["mode_note"] = (
1991
+ "Windows has no POSIX permission bits: chmod only toggles the "
1992
+ "read-only attribute, so the execute bit was not set. Run scripts "
1993
+ "through their interpreter (python script.py) or give them a "
1994
+ "recognised extension (.ps1/.bat/.cmd)."
1995
+ )
1996
+ else:
1997
+ result["mode_applied"] = effective == mode
1998
+ return result
791
1999
  except Exception as exc:
792
2000
  return {"error": "write_file failed: %s" % exc}
793
2001
 
@@ -1109,14 +2317,26 @@ def _pid_alive_windows(pid: int) -> bool:
1109
2317
  """
1110
2318
  try:
1111
2319
  import ctypes
2320
+ from ctypes import wintypes
2321
+
1112
2322
  kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
2323
+ # Declare the signatures: a HANDLE is pointer-sized, and letting ctypes
2324
+ # default to c_int truncates it on 64-bit, so CloseHandle would receive a
2325
+ # value that is not the handle OpenProcess returned.
2326
+ kernel32.OpenProcess.restype = wintypes.HANDLE
2327
+ kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
2328
+ kernel32.GetExitCodeProcess.restype = wintypes.BOOL
2329
+ kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
2330
+ kernel32.CloseHandle.restype = wintypes.BOOL
2331
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
2332
+
1113
2333
  PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
1114
2334
  STILL_ACTIVE = 259
1115
2335
  handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
1116
2336
  if not handle:
1117
2337
  return False
1118
2338
  try:
1119
- code = ctypes.c_ulong()
2339
+ code = wintypes.DWORD()
1120
2340
  if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
1121
2341
  return False
1122
2342
  return code.value == STILL_ACTIVE
@@ -1236,6 +2456,55 @@ def _pump(stream, path: str, budget: _OutputBudget) -> None:
1236
2456
  pass
1237
2457
 
1238
2458
 
2459
+ def _wake_lock_for_job() -> Optional[bool]:
2460
+ """Hold Android's partial wake lock while a background job runs.
2461
+
2462
+ Android freezes a background app once the screen goes off, which turns a
2463
+ perfectly healthy build into a job that never finishes. The installer's boot
2464
+ script takes the lock once at boot, but nothing re-checks it afterwards, so a
2465
+ job start is the honest place to make sure it is held. Returns ``True`` when a
2466
+ lock is held for this job, ``False`` when the phone refused, and ``None`` on a
2467
+ platform where the question does not apply.
2468
+ """
2469
+ if _termux is None or _device_scenario() != "termux":
2470
+ return None
2471
+ try:
2472
+ result = _termux.wake_lock("acquire")
2473
+ except Exception:
2474
+ return False
2475
+ return bool(result.get("held") or result.get("count"))
2476
+
2477
+
2478
+ #: Jobs whose wake lock has already been released in this process. Both the reaper
2479
+ #: and job_kill finalise the same job (a killed job is reaped too), and a second
2480
+ #: release is not harmless: the lock is ref-counted once per job, so releasing twice
2481
+ #: unlocks a *different, still-running* job on the same phone.
2482
+ _WAKE_LOCK_RELEASED: set = set()
2483
+
2484
+
2485
+ def _release_job_wake_lock(job_id: str) -> None:
2486
+ """Release the wake lock this job took, at most once per job.
2487
+
2488
+ The guard lives in memory because both callers are threads of this process; the
2489
+ metadata write only records the fact so ``job_list`` stops claiming the job
2490
+ still holds it.
2491
+ """
2492
+ if _termux is None:
2493
+ return
2494
+ with _META_LOCK:
2495
+ if job_id in _WAKE_LOCK_RELEASED:
2496
+ return
2497
+ meta = _read_json(_meta_path(job_id)) or {}
2498
+ if not meta.get("wake_lock"):
2499
+ return
2500
+ _WAKE_LOCK_RELEASED.add(job_id)
2501
+ _update_meta(job_id, wake_lock=False)
2502
+ try:
2503
+ _termux.wake_lock("release")
2504
+ except Exception:
2505
+ pass
2506
+
2507
+
1239
2508
  def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
1240
2509
  """Background thread: collect a finished job and persist its result."""
1241
2510
  try:
@@ -1250,26 +2519,32 @@ def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
1250
2519
  except Exception:
1251
2520
  pass
1252
2521
 
1253
- with _META_LOCK:
1254
- meta = _read_json(_meta_path(job_id))
1255
- if meta is None:
1256
- return
1257
- if meta.get("status") == "killed":
1258
- status = "killed"
1259
- else:
1260
- status = "done"
1261
- meta.update(
1262
- {
1263
- "status": status,
1264
- "exit_code": returncode,
1265
- "duration": round(time.time() - started_mono, 3),
1266
- "finished_at": _now_iso(),
1267
- }
1268
- )
1269
- try:
1270
- _write_json_atomic(_meta_path(job_id), meta)
1271
- except Exception:
1272
- pass
2522
+ # The wake lock is released in a `finally`: a job that finished must not keep
2523
+ # Android awake because its metadata could not be read back (the early return
2524
+ # below used to leak exactly that way).
2525
+ try:
2526
+ with _META_LOCK:
2527
+ meta = _read_json(_meta_path(job_id))
2528
+ if meta is None:
2529
+ return
2530
+ if meta.get("status") == "killed":
2531
+ status = "killed"
2532
+ else:
2533
+ status = "done"
2534
+ meta.update(
2535
+ {
2536
+ "status": status,
2537
+ "exit_code": returncode,
2538
+ "duration": round(time.time() - started_mono, 3),
2539
+ "finished_at": _now_iso(),
2540
+ }
2541
+ )
2542
+ try:
2543
+ _write_json_atomic(_meta_path(job_id), meta)
2544
+ except Exception:
2545
+ pass
2546
+ finally:
2547
+ _release_job_wake_lock(job_id)
1273
2548
 
1274
2549
 
1275
2550
  def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
@@ -1308,6 +2583,7 @@ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
1308
2583
  bufsize=0,
1309
2584
  start_new_session=not _IS_WINDOWS,
1310
2585
  creationflags=_POPEN_FLAGS,
2586
+ env=_child_env(),
1311
2587
  )
1312
2588
  except Exception as exc:
1313
2589
  return {"error": "failed to start job: %s" % exc}
@@ -1321,6 +2597,10 @@ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
1321
2597
  for thread in readers:
1322
2598
  thread.start()
1323
2599
  started_mono = time.time()
2600
+ # On a phone the lock is taken for as long as this job runs and released in
2601
+ # _reap / job_kill. It is deliberately not an argument: a model that forgot
2602
+ # to pass it would produce a job that silently stops when the screen does.
2603
+ wake_lock = _wake_lock_for_job()
1324
2604
  _JOBS[job_id] = {
1325
2605
  "proc": proc,
1326
2606
  "readers": readers,
@@ -1341,24 +2621,40 @@ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
1341
2621
  "finished_at": None,
1342
2622
  "stdout_path": stdout_path,
1343
2623
  "stderr_path": stderr_path,
2624
+ "wake_lock": bool(wake_lock),
1344
2625
  }
1345
2626
  try:
1346
2627
  _write_json_atomic(_meta_path(job_id), meta)
1347
2628
  except Exception as exc:
1348
2629
  _JOBS.pop(job_id, None)
2630
+ # The job never really started from the caller's point of view, so the
2631
+ # lock taken for it must not outlive the attempt.
2632
+ if wake_lock:
2633
+ try:
2634
+ _termux.wake_lock("release") # type: ignore[union-attr]
2635
+ except Exception:
2636
+ pass
1349
2637
  return {"error": "failed to persist job metadata: %s" % exc}
1350
2638
 
1351
2639
  reaper = threading.Thread(target=_reap, args=(job_id, proc, started_mono), daemon=True)
1352
2640
  reaper.start()
1353
2641
  _JOBS[job_id]["reaper"] = reaper
1354
2642
 
1355
- return {
2643
+ result = {
1356
2644
  "job_id": job_id,
1357
2645
  "pid": proc.pid,
1358
2646
  "pid_start": meta.get("pid_start"),
1359
2647
  "command": command,
1360
2648
  "started_at": started_at,
1361
2649
  }
2650
+ if wake_lock is not None:
2651
+ result["wake_lock"] = bool(wake_lock)
2652
+ if not wake_lock:
2653
+ result["wake_lock_note"] = (
2654
+ "Android's wake lock could not be taken, so this job may be frozen "
2655
+ "when the screen turns off; install Termux:API and disable battery "
2656
+ "optimisation for Termux (docs/TERMUX.md)")
2657
+ return result
1362
2658
  except Exception as exc:
1363
2659
  return {"error": "run_job failed: %s" % exc}
1364
2660
 
@@ -1403,8 +2699,11 @@ def _tool_job_output(args: Dict[str, Any]) -> Dict[str, Any]:
1403
2699
 
1404
2700
  def _read_text(path: str) -> str:
1405
2701
  try:
1406
- with open(path, "r", encoding="utf-8", errors="replace") as handle:
1407
- return _sanitize_output(handle.read())
2702
+ # The pump stores raw bytes: the child's encoding is discovered
2703
+ # here, the same way bash_exec decodes its own capture. Reading
2704
+ # as UTF-8 only would mangle a cmd.exe job on a non-English box.
2705
+ with open(path, "rb") as handle:
2706
+ return _sanitize_output(_decode_output(handle.read()))
1408
2707
  except Exception:
1409
2708
  return ""
1410
2709
 
@@ -1445,10 +2744,22 @@ if _IS_WINDOWS:
1445
2744
 
1446
2745
  def _kill_tree_windows(pid: int) -> None:
1447
2746
  """End a job and its children on Windows (there are no process groups to signal)."""
1448
- proc = subprocess.run(["taskkill", "/T", "/F", "/PID", str(int(pid))],
1449
- capture_output=True, text=True, timeout=15)
1450
- if proc.returncode != 0:
1451
- raise OSError((proc.stderr or proc.stdout or "taskkill failed").strip())
2747
+ proc = subprocess.run(
2748
+ ["taskkill", "/T", "/F", "/PID", str(int(pid))],
2749
+ stdin=subprocess.DEVNULL,
2750
+ capture_output=True,
2751
+ timeout=15,
2752
+ creationflags=_POPEN_FLAGS,
2753
+ )
2754
+ if proc.returncode == 0:
2755
+ return
2756
+ detail = (_decode_output(proc.stderr) or _decode_output(proc.stdout)).strip()
2757
+ # "ERROR: The process "1234" not found." means the tree is already gone —
2758
+ # the usual case when a short-lived job finishes between job_output and
2759
+ # job_kill. That is success for the caller, not a failure to report.
2760
+ if "not found" in detail.lower() or "не найдено" in detail.lower() or not detail:
2761
+ return
2762
+ raise OSError(detail)
1452
2763
 
1453
2764
 
1454
2765
  def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
@@ -1496,6 +2807,7 @@ def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
1496
2807
 
1497
2808
  if delivered:
1498
2809
  _update_meta(job_id, status="killed", finished_at=_now_iso(), duration=_duration_from_meta(meta))
2810
+ _release_job_wake_lock(job_id)
1499
2811
  return {"ok": True, "status": "killed"}
1500
2812
  except Exception as exc:
1501
2813
  return {"error": "job_kill failed: %s" % exc}
@@ -1528,6 +2840,9 @@ def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
1528
2840
  "exit_code": meta.get("exit_code"),
1529
2841
  "duration": meta.get("duration"),
1530
2842
  "started_at": meta.get("started_at"),
2843
+ # On a phone this says whether the job is protected from Android's
2844
+ # background freezer while the screen is off.
2845
+ "wake_lock": meta.get("wake_lock", None),
1531
2846
  })
1532
2847
  return {"jobs": jobs, "count": len(jobs), "total": len(metas),
1533
2848
  "note": ("showing the %d most recent of %d; pass limit=<n> for more"
@@ -1536,6 +2851,63 @@ def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
1536
2851
  return {"error": "job_list failed: %s" % exc}
1537
2852
 
1538
2853
 
2854
+ # ---------------------------------------------------------------------------
2855
+ # Public web shares (core/web_share.py)
2856
+ #
2857
+ # ``share_file``/``serve_dir`` start a real loopback-only static web server on
2858
+ # this node; the gateway relays GET/HEAD from
2859
+ # https://<shared-domain>/<node>/<name>-<secret>/... into it over the tunnel.
2860
+ # The module is optional (an older checkout may not carry it), so every tool
2861
+ # answers an honest "update the node" error instead of raising.
2862
+ #
2863
+ # ``_http_share`` is the internal relay target and is deliberately NOT part of
2864
+ # TOOLS, so it never appears in tools/list and cannot be called by the model.
2865
+ # ---------------------------------------------------------------------------
2866
+
2867
+ def _web_share_error() -> Dict[str, Any]:
2868
+ return {
2869
+ "error": "web sharing is unavailable on this node: core/web_share.py is missing. "
2870
+ "Re-run the installer (or update the node) to get the share tools."
2871
+ }
2872
+
2873
+
2874
+ def _tool_share_file(args: Dict[str, Any]) -> Dict[str, Any]:
2875
+ if _READ_ONLY:
2876
+ return {"error": "MESH_READ_ONLY is set: publishing files is disabled on this node"}
2877
+ if _web_share is None:
2878
+ return _web_share_error()
2879
+ return _web_share.share_file(args.get("path"), args.get("name"), args.get("overwrite", False))
2880
+
2881
+
2882
+ def _tool_serve_dir(args: Dict[str, Any]) -> Dict[str, Any]:
2883
+ if _READ_ONLY:
2884
+ return {"error": "MESH_READ_ONLY is set: publishing directories is disabled on this node"}
2885
+ if _web_share is None:
2886
+ return _web_share_error()
2887
+ return _web_share.serve_dir(args.get("path"), args.get("name"))
2888
+
2889
+
2890
+ def _tool_share_list(args: Dict[str, Any]) -> Dict[str, Any]:
2891
+ if _web_share is None:
2892
+ return _web_share_error()
2893
+ return _web_share.share_list()
2894
+
2895
+
2896
+ def _tool_unshare(args: Dict[str, Any]) -> Dict[str, Any]:
2897
+ if _web_share is None:
2898
+ return _web_share_error()
2899
+ target = args.get("name") or args.get("slug") or args.get("url")
2900
+ return _web_share.unshare(target)
2901
+
2902
+
2903
+ def _tool_http_share(args: Dict[str, Any]) -> Dict[str, Any]:
2904
+ """Internal: serve one public GET/HEAD relayed by the gateway."""
2905
+ if _web_share is None:
2906
+ return {"status": 404, "headers": {"content-type": "text/plain; charset=utf-8"},
2907
+ "body_b64": ""}
2908
+ return _web_share.handle_http_request(args)
2909
+
2910
+
1539
2911
  # ---------------------------------------------------------------------------
1540
2912
  # Tool definitions (MCP schemas)
1541
2913
  # ---------------------------------------------------------------------------
@@ -1548,25 +2920,142 @@ def _schema(properties: Dict[str, Any], required: Optional[List[str]] = None) ->
1548
2920
  return schema
1549
2921
 
1550
2922
 
2923
+ # ---------------------------------------------------------------------------
2924
+ # MCP tool annotations - when the client is allowed to run without asking
2925
+ # ---------------------------------------------------------------------------
2926
+ # A client such as Gemini Spark reads ``Tool.annotations`` from ``tools/list`` to
2927
+ # decide whether it must stop and ask the user "confirm this action?" before a
2928
+ # call. The spec's defaults are the pessimistic ones (``readOnlyHint`` false,
2929
+ # ``destructiveHint`` true), so a tool advertised with *no* annotations is treated
2930
+ # as if it deleted something - which is exactly why the user was asked to confirm
2931
+ # every single call, while other MCP servers (whose tools do carry hints) ran
2932
+ # without a prompt.
2933
+ #
2934
+ # Four explicit profiles replace that guesswork:
2935
+ #
2936
+ # read-only the tool only observes the host -> never ask
2937
+ # local write it changes something on the host, but nothing is installed or
2938
+ # removed -> do not ask
2939
+ # publish the result is reachable from the internet -> no install/delete
2940
+ # destructive it installs (mesh_update) or deletes/revokes (unshare)
2941
+ # something -> ask first
2942
+ #
2943
+ # The node's own guards are untouched by this: ``MESH_READ_ONLY`` and
2944
+ # ``MESH_WRITE_ROOTS`` still refuse work at the tool implementation level.
2945
+ _ANNOTATIONS_READ_ONLY: Dict[str, Any] = {
2946
+ "readOnlyHint": True,
2947
+ "destructiveHint": False,
2948
+ "idempotentHint": True,
2949
+ "openWorldHint": False,
2950
+ }
2951
+
2952
+ _ANNOTATIONS_LOCAL_WRITE: Dict[str, Any] = {
2953
+ "readOnlyHint": False,
2954
+ "destructiveHint": False,
2955
+ "idempotentHint": False,
2956
+ "openWorldHint": False,
2957
+ }
2958
+
2959
+ #: Password-like public links are handed out here, so the client is told the tool
2960
+ #: reaches outside the host (``openWorldHint``). It is still not destructive.
2961
+ _ANNOTATIONS_PUBLISH: Dict[str, Any] = {
2962
+ "readOnlyHint": False,
2963
+ "destructiveHint": False,
2964
+ "idempotentHint": False,
2965
+ "openWorldHint": True,
2966
+ }
2967
+
2968
+ _ANNOTATIONS_DESTRUCTIVE: Dict[str, Any] = {
2969
+ "readOnlyHint": False,
2970
+ "destructiveHint": True,
2971
+ "idempotentHint": False,
2972
+ "openWorldHint": False,
2973
+ }
2974
+
2975
+ #: Camera, microphone, location, fingerprint, USB, infrared, SMS, contacts and the
2976
+ #: call log. They delete nothing, so "destructive" is not literally true - but
2977
+ #: ``destructiveHint`` is the only hint clients such as Gemini Spark reliably turn
2978
+ #: into a "confirm this action?" prompt, and these calls observe the physical world
2979
+ #: (or the private messages of the person holding the phone) without the operator
2980
+ #: necessarily watching. Marking them destructive is therefore the honest safety
2981
+ #: choice, and the README documents the trade-off.
2982
+ _ANNOTATIONS_SENSITIVE: Dict[str, Any] = {
2983
+ "readOnlyHint": False,
2984
+ "destructiveHint": True,
2985
+ "idempotentHint": False,
2986
+ "openWorldHint": False,
2987
+ }
2988
+
2989
+ #: The confirmation policy, one entry per advertised tool. Classify every new tool
2990
+ #: here: a tool missing from this table stays unannotated on purpose, which means
2991
+ #: the client falls back to its default "ask first" - the safe answer for a tool
2992
+ #: nobody has judged yet. ``tests/test_tool_annotations.py`` fails on such a tool
2993
+ #: so the decision cannot be forgotten.
2994
+ TOOL_ANNOTATIONS: Dict[str, Dict[str, Any]] = {
2995
+ # --- read-only: looking around never needs a prompt ----------------------
2996
+ "mesh_status": _ANNOTATIONS_READ_ONLY,
2997
+ "system_info": _ANNOTATIONS_READ_ONLY,
2998
+ "system_vitals": _ANNOTATIONS_READ_ONLY,
2999
+ "get_orchestration_skill": _ANNOTATIONS_READ_ONLY,
3000
+ "list_dir": _ANNOTATIONS_READ_ONLY,
3001
+ "read_file": _ANNOTATIONS_READ_ONLY,
3002
+ "grep_search": _ANNOTATIONS_READ_ONLY,
3003
+ "glob_find": _ANNOTATIONS_READ_ONLY,
3004
+ "job_output": _ANNOTATIONS_READ_ONLY,
3005
+ "job_list": _ANNOTATIONS_READ_ONLY,
3006
+ "share_list": _ANNOTATIONS_READ_ONLY,
3007
+ # --- local work: writes and commands, but no install and no delete -------
3008
+ "bash_exec": _ANNOTATIONS_LOCAL_WRITE,
3009
+ "run_job": _ANNOTATIONS_LOCAL_WRITE,
3010
+ # Stopping a background job is not an install and not a delete, and
3011
+ # bash_exec can terminate the same process anyway; a prompt here would only
3012
+ # add noise, so it carries the same profile as the rest of the local work.
3013
+ "job_kill": _ANNOTATIONS_LOCAL_WRITE,
3014
+ "write_file": _ANNOTATIONS_LOCAL_WRITE,
3015
+ "edit_file": _ANNOTATIONS_LOCAL_WRITE,
3016
+ # --- device branch: the node's own phone or laptop -----------------------
3017
+ # Reading the device changes nothing, so it never prompts; the reversible
3018
+ # actions are ordinary local work; what the physical world can observe (camera,
3019
+ # microphone, location) and what is private to a person (messages, contacts)
3020
+ # always asks first.
3021
+ "device_info": _ANNOTATIONS_READ_ONLY,
3022
+ "device_control": _ANNOTATIONS_LOCAL_WRITE,
3023
+ "device_capture": _ANNOTATIONS_SENSITIVE,
3024
+ "device_messages": _ANNOTATIONS_SENSITIVE,
3025
+ # --- publishing something on the internet -------------------------------
3026
+ "share_file": _ANNOTATIONS_PUBLISH,
3027
+ "serve_dir": _ANNOTATIONS_PUBLISH,
3028
+ # --- destructive: install or delete/revoke -> confirm first --------------
3029
+ "mesh_update": _ANNOTATIONS_DESTRUCTIVE,
3030
+ "unshare": _ANNOTATIONS_DESTRUCTIVE,
3031
+ }
3032
+
1551
3033
  TOOLS: List[Dict[str, Any]] = [
1552
3034
  {
1553
3035
  "name": "mesh_status",
1554
3036
  "title": "Mesh Status",
1555
3037
  "description": ("Confirm this node is reachable. Call this first if you think the host is "
1556
- "offline; it reports live evidence from the host itself."),
3038
+ "offline; it reports live evidence from the host itself, plus what kind "
3039
+ "of machine answered (device_class, scenario) and its battery level."),
1557
3040
  "inputSchema": _schema({}),
1558
3041
  },
1559
3042
  {
1560
3043
  "name": "system_info",
1561
3044
  "title": "System Info",
1562
3045
  "description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
1563
- "top processes and the current wallpaper."),
3046
+ "top processes, the current wallpaper, and the device blocks - "
3047
+ "battery and charging, network and signal, language, time, timezone, "
3048
+ "CPU, RAM, storage, cameras, microphones and sensors. On a phone it "
3049
+ "reads Android through the termux-api commands; on a laptop it reads "
3050
+ "the local system. Every device block carries available/source, and a "
3051
+ "block the platform cannot answer says why instead of showing a zero."),
1564
3052
  "inputSchema": _schema({}),
1565
3053
  },
1566
3054
  {
1567
3055
  "name": "system_vitals",
1568
3056
  "title": "System Vitals",
1569
- "description": "Retrieve CPU, RAM and disk metrics of the local host.",
3057
+ "description": ("Retrieve CPU, RAM and disk metrics of the local host, plus the "
3058
+ "battery block (and thermal sensors where the platform exposes them)."),
1570
3059
  "inputSchema": _schema({}),
1571
3060
  },
1572
3061
  {
@@ -1585,9 +3074,12 @@ TOOLS: List[Dict[str, Any]] = [
1585
3074
  "name": "bash_exec",
1586
3075
  "title": "Execute Bash Command",
1587
3076
  "description": (
1588
- "Execute a shell command on the local host. Output is paginated: when the result is "
3077
+ "Execute a shell command on the local host. The shell matches the host, not this "
3078
+ "tool's name: run system_info and read command_shell first (bash on Linux/macOS, "
3079
+ "PowerShell or cmd.exe on Windows) and write the command for that shell. "
3080
+ "Output is paginated: when the result is "
1589
3081
  "cut, call again with cursor=next_cursor to continue (nothing is dropped). Large "
1590
- "outputs (>256 KB) are spooled to a file returned in saved_to."
3082
+ "outputs (above 2 MB) are spooled to a file returned in saved_to."
1591
3083
  ),
1592
3084
  "inputSchema": _schema(
1593
3085
  {
@@ -1620,13 +3112,17 @@ TOOLS: List[Dict[str, Any]] = [
1620
3112
  {
1621
3113
  "name": "write_file",
1622
3114
  "title": "Write File",
1623
- "description": "Atomically create or overwrite a file (tmp file + os.replace).",
3115
+ "description": (
3116
+ "Atomically create or overwrite a file (tmp file + os.replace). On Windows the "
3117
+ "optional mode is not honoured (no POSIX permission bits) and the result says so."
3118
+ ),
1624
3119
  "inputSchema": _schema(
1625
3120
  {
1626
3121
  "path": {"type": "string", "description": "File path to write."},
1627
3122
  "content": {"type": "string", "description": "Full file content."},
1628
3123
  "create_dirs": {"type": "boolean", "description": "Create parent directories (default true)."},
1629
- "mode": {"type": "string", "description": "Octal file mode, e.g. \"0644\"."},
3124
+ "mode": {"type": "string",
3125
+ "description": "Octal file mode, e.g. \"0644\". POSIX only; ignored on Windows."},
1630
3126
  },
1631
3127
  ["path", "content"],
1632
3128
  ),
@@ -1682,7 +3178,9 @@ TOOLS: List[Dict[str, Any]] = [
1682
3178
  {
1683
3179
  "name": "run_job",
1684
3180
  "title": "Run Background Job",
1685
- "description": "Start a command in the background; returns a job_id for job_output/job_kill.",
3181
+ "description": "Start a command in the background; returns a job_id for job_output/job_kill. "
3182
+ "On an Android/Termux node the job's wake lock is taken automatically so "
3183
+ "Android does not freeze it while the screen is off.",
1686
3184
  "inputSchema": _schema(
1687
3185
  {
1688
3186
  "command": {"type": "string", "description": "Shell command to run in the background."},
@@ -1725,17 +3223,240 @@ TOOLS: List[Dict[str, Any]] = [
1725
3223
  "limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)."},
1726
3224
  }),
1727
3225
  },
3226
+ {
3227
+ "name": "mesh_update",
3228
+ "title": "Mesh Update (self-update)",
3229
+ "description": (
3230
+ "Report, check for, or install a newer release of this node's own code from "
3231
+ "GitHub. action=status reads local state without network; action=check asks "
3232
+ "GitHub Releases; action=apply downloads the release payload, verifies its "
3233
+ "published SHA-256, installs it with a rollback backup and restarts the agent "
3234
+ "(the tunnel drops for a few seconds; the tool result is still sent first). "
3235
+ "Use status or check first, and apply only when the operator wants this node "
3236
+ "updated now."
3237
+ ),
3238
+ "inputSchema": _schema({
3239
+ "action": {"type": "string", "enum": ["status", "check", "apply"],
3240
+ "description": "What to do (default: status)."},
3241
+ "force": {"type": "boolean",
3242
+ "description": "Ignore the check interval and the failed-apply backoff."},
3243
+ "offline": {"type": "boolean",
3244
+ "description": "check: report the cached answer without touching the network."},
3245
+ "restart": {"type": "boolean",
3246
+ "description": "apply: restart the node afterwards (default true)."},
3247
+ }),
3248
+ },
3249
+ {
3250
+ "name": "device_info",
3251
+ "title": "Device Info",
3252
+ "description": (
3253
+ "One-call report for the device this node runs on: battery and charging, "
3254
+ "network interfaces with Wi-Fi and cellular signal strength, language, time and "
3255
+ "timezone, CPU, RAM, storage, cameras, microphones, sensors and what the platform "
3256
+ "can actually reach. On an Android/Termux node it reads the phone through the "
3257
+ "termux-api commands; on a laptop or desktop it reads the local system. Every "
3258
+ "section carries available/source, so 'not exposed here' is never reported as a "
3259
+ "zero - and on a non-phone the phone-only sections explain themselves instead of "
3260
+ "failing. Use device_control, device_capture or device_messages to act."
3261
+ ),
3262
+ "inputSchema": _schema({
3263
+ "section": {"type": "string",
3264
+ "enum": ["summary", "all", "device", "battery", "network", "locale",
3265
+ "time", "hardware", "storage", "cameras", "microphones",
3266
+ "sensors", "capabilities"],
3267
+ "description": "Which blocks to return (default: all)."},
3268
+ "sensor": {"type": "string",
3269
+ "description": "Name one sensor to take a single live sample from "
3270
+ "(Android only; needs a name from the sensors block)."},
3271
+ "fresh": {"type": "boolean",
3272
+ "description": "Bypass the short-lived cache (default false)."},
3273
+ "quick": {"type": "boolean",
3274
+ "description": "Skip the slow sections: network, cameras, microphones, "
3275
+ "sensors (default false)."},
3276
+ }),
3277
+ },
3278
+ {
3279
+ "name": "device_control",
3280
+ "title": "Device Control",
3281
+ "description": (
3282
+ "Reversible actions on the node's own device: torch, vibrate, volume, screen "
3283
+ "brightness, text-to-speech, toast, notifications, clipboard, media playback, "
3284
+ "media scan, wake lock, download, opening or sharing a file, a text dialog and "
3285
+ "the wallpaper. Each action is a fixed termux-api command on Android and an "
3286
+ "explained no-op elsewhere; the result names the command and its argv so nothing "
3287
+ "is guessed. Nothing here is destructive, and all of it is refused when the node "
3288
+ "runs with MESH_READ_ONLY=1."
3289
+ ),
3290
+ "inputSchema": _schema({
3291
+ "action": {"type": "string",
3292
+ "enum": list(DEVICE_CONTROL_ACTIONS),
3293
+ "description": "Action to perform."},
3294
+ "on": {"type": "boolean", "description": "torch: true lights it, false puts it out."},
3295
+ "value": {"type": "integer",
3296
+ "description": "vibrate: milliseconds; volume: level 0-15; brightness: 0-255."},
3297
+ "stream": {"type": "string", "enum": list(_DEVICE_STREAMS),
3298
+ "description": "volume: which audio stream (default music)."},
3299
+ "text": {"type": "string",
3300
+ "description": "tts_speak/toast/notify/clipboard_set/dialog body; media: "
3301
+ "play|pause|stop|info."},
3302
+ "title": {"type": "string", "description": "notify/dialog: heading text."},
3303
+ "id": {"type": "string",
3304
+ "description": "notify_remove: the id the notification was posted with."},
3305
+ "url": {"type": "string", "description": "download/open/wallpaper: URL to use."},
3306
+ "path": {"type": "string",
3307
+ "description": "open/share/media/download/media_scan/wallpaper: file on the node."},
3308
+ "state": {"type": "string", "enum": ["acquire", "release", "status"],
3309
+ "description": "wakelock: what to do (default status)."},
3310
+ "timeout_sec": {"type": "integer",
3311
+ "description": "dialog: seconds to wait for the person (5-120, default 30)."},
3312
+ }, ["action"]),
3313
+ },
3314
+ {
3315
+ "name": "device_capture",
3316
+ "title": "Device Capture (asks first)",
3317
+ "description": (
3318
+ "Camera, microphone, location, fingerprint, USB and infrared actions. These "
3319
+ "observe the physical world or authenticate the person holding the phone, so the "
3320
+ "client always asks for confirmation first. A photo or a recording is written to "
3321
+ "the node's capture directory and is never uploaded anywhere by itself; "
3322
+ "fingerprint returns only the verdict, never biometric data; USB access requests "
3323
+ "permission and returns the descriptor but deliberately refuses to run a program "
3324
+ "for you; and on Android 11+ the Termux:API app has to be in the foreground for a "
3325
+ "recording. Refused when MESH_READ_ONLY=1."
3326
+ ),
3327
+ "inputSchema": _schema({
3328
+ "action": {"type": "string",
3329
+ "enum": list(DEVICE_CAPTURE_ACTIONS),
3330
+ "description": "Capture action to perform."},
3331
+ "camera_id": {"type": "integer",
3332
+ "description": "camera_photo: id from camera_list (default 0)."},
3333
+ "path": {"type": "string",
3334
+ "description": "camera_photo/mic_record_start: output file (default: the "
3335
+ "node's capture directory); usb_access: device path from "
3336
+ "usb_list."},
3337
+ "seconds": {"type": "integer",
3338
+ "description": "mic_record_start: recording length limit in seconds."},
3339
+ "provider": {"type": "string", "enum": ["gps", "network", "passive"],
3340
+ "description": "location: which Android provider to use (default gps)."},
3341
+ "frequency": {"type": "integer",
3342
+ "description": "infrared_transmit: carrier frequency in Hz."},
3343
+ "pattern": {"type": "string",
3344
+ "description": "infrared_transmit: durations in microseconds, e.g. "
3345
+ "1000,2000,1000."},
3346
+ }, ["action"]),
3347
+ },
3348
+ {
3349
+ "name": "device_messages",
3350
+ "title": "Device Messages (asks first)",
3351
+ "description": (
3352
+ "SMS, call log, contacts and placing a call. This is private data about the person "
3353
+ "who owns the phone, so the client asks for confirmation AND the node refuses "
3354
+ "until its operator enables it with MESH_DEVICE_PIM=1. The refusal names the flag "
3355
+ "and the Android permissions to grant, so the answer is an instruction rather than "
3356
+ "a dead end. Sending an SMS and placing a call are also refused under "
3357
+ "MESH_READ_ONLY=1."
3358
+ ),
3359
+ "inputSchema": _schema({
3360
+ "action": {"type": "string", "enum": list(DEVICE_MESSAGES_ACTIONS),
3361
+ "description": "Message or PIM action to perform."},
3362
+ "number": {"type": "string", "description": "sms_send/call: recipient number."},
3363
+ "text": {"type": "string", "description": "sms_send: message body."},
3364
+ "limit": {"type": "integer", "description": "How many records (1-50, default 10)."},
3365
+ "offset": {"type": "integer", "description": "Record offset, for paging."},
3366
+ "type": {"type": "string",
3367
+ "description": "sms_list/call_log: filter, e.g. inbox/sent or "
3368
+ "incoming/outgoing/missed."},
3369
+ "query": {"type": "string", "description": "contacts: filter by name or number."},
3370
+ }, ["action"]),
3371
+ },
3372
+ {
3373
+ "name": "share_file",
3374
+ "title": "Share File (public URL)",
3375
+ "description": (
3376
+ "Publish ONE local file on the internet through the Mesh gateway. The file is copied "
3377
+ "into the node's share root and a public HTTPS link is returned on the shared domain: "
3378
+ "https://<shared-domain>/<node>/<name>-<random>/<filename>. Anyone who has the link can "
3379
+ "read the file (the random part of the path is the credential), so treat the link like a "
3380
+ "password. The node must be connected to the Mesh gateway for the link to work. Use "
3381
+ "share_list to see active shares and unshare to revoke one."
3382
+ ),
3383
+ "inputSchema": _schema(
3384
+ {
3385
+ "path": {"type": "string", "description": "Path of the local file to publish."},
3386
+ "name": {"type": "string",
3387
+ "description": "Human name in the URL (default: the file name without extension)."},
3388
+ "overwrite": {"type": "boolean",
3389
+ "description": "Replace an existing share with the same name (default false)."},
3390
+ },
3391
+ ["path"],
3392
+ ),
3393
+ },
3394
+ {
3395
+ "name": "serve_dir",
3396
+ "title": "Serve Directory (public URL)",
3397
+ "description": (
3398
+ "Start a static web server for a local directory and return its public HTTPS link: "
3399
+ "https://<shared-domain>/<node>/<name>-<random>/. The directory is served in place "
3400
+ "(no copy): index.html is used when present, otherwise a directory listing is shown. "
3401
+ "Serving is read-only (GET/HEAD only) and the random path segment is the credential. "
3402
+ "Use unshare to stop the server; the directory itself is never deleted."
3403
+ ),
3404
+ "inputSchema": _schema(
3405
+ {
3406
+ "path": {"type": "string", "description": "Local directory to serve."},
3407
+ "name": {"type": "string",
3408
+ "description": "Human name in the URL (default: the directory name)."},
3409
+ },
3410
+ ["path"],
3411
+ ),
3412
+ },
3413
+ {
3414
+ "name": "share_list",
3415
+ "title": "Share List",
3416
+ "description": "List active public shares with their URLs, roots and whether the local server is running.",
3417
+ "inputSchema": _schema({}),
3418
+ },
3419
+ {
3420
+ "name": "unshare",
3421
+ "title": "Unshare (revoke URL)",
3422
+ "description": (
3423
+ "Stop a public share and revoke its link. Accepts the share name, its slug or the full "
3424
+ "URL. A file share also deletes the copy that was made in the share root; a served "
3425
+ "directory is left untouched on disk."
3426
+ ),
3427
+ "inputSchema": _schema(
3428
+ {"name": {"type": "string", "description": "Share name, slug or public URL from share_list."}},
3429
+ ["name"],
3430
+ ),
3431
+ },
1728
3432
  ]
1729
3433
 
1730
3434
 
3435
+ # Attach the confirmation policy to the advertised specs. Kept out of the literal
3436
+ # above so the policy reads as one table instead of twenty scattered flags.
3437
+ def _attach_annotations(tools: List[Dict[str, Any]]) -> None:
3438
+ for spec in tools:
3439
+ profile = TOOL_ANNOTATIONS.get(spec.get("name"))
3440
+ if profile is not None:
3441
+ spec["annotations"] = dict(profile)
3442
+
3443
+
3444
+ _attach_annotations(TOOLS)
3445
+
3446
+
1731
3447
  # ---------------------------------------------------------------------------
1732
3448
  # Dispatch
1733
3449
  # ---------------------------------------------------------------------------
1734
3450
 
1735
3451
  _HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
1736
3452
  "mesh_status": _tool_mesh_status,
3453
+ "mesh_update": _tool_mesh_update,
1737
3454
  "system_info": _tool_system_info,
1738
3455
  "system_vitals": _tool_system_vitals,
3456
+ "device_info": _tool_device_info,
3457
+ "device_control": _tool_device_control,
3458
+ "device_capture": _tool_device_capture,
3459
+ "device_messages": _tool_device_messages,
1739
3460
  "get_orchestration_skill": _tool_get_orchestration_skill,
1740
3461
  "list_dir": _tool_list_dir,
1741
3462
  "bash_exec": _tool_bash_exec,
@@ -1748,6 +3469,13 @@ _HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
1748
3469
  "job_output": _tool_job_output,
1749
3470
  "job_kill": _tool_job_kill,
1750
3471
  "job_list": _tool_job_list,
3472
+ "share_file": _tool_share_file,
3473
+ "serve_dir": _tool_serve_dir,
3474
+ "share_list": _tool_share_list,
3475
+ "unshare": _tool_unshare,
3476
+ # Internal gateway relay (never advertised in TOOLS / tools/list). The deployed
3477
+ # gateway reaches a share by calling this tool over the tunnel.
3478
+ "_http_share": _tool_http_share,
1751
3479
  }
1752
3480
 
1753
3481