gemini-computer-use 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1658 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """core/mcp_tools.py - shared Antigravity Mesh tool implementation.
4
+
5
+ This module is the single place that contains tool *logic*. Both consumers
6
+ (the asyncio tunnel node ``core/agent.py`` and the standalone HTTP server
7
+ ``core/server.py``) are thin transports that only forward to
8
+ :func:`call_tool`.
9
+
10
+ Design rules (see the project contract):
11
+
12
+ * standard library only, Python 3.8+;
13
+ * :func:`call_tool` never raises - every failure is returned as
14
+ ``{"error": "<human readable>"}``;
15
+ * output pagination never throws the middle away: callers advance a ``cursor``;
16
+ * large outputs are spooled to a file instead of being dropped;
17
+ * every mutating operation is checked against ``read_only`` / ``write_roots``.
18
+
19
+ Public interface::
20
+
21
+ TOOLS: list[dict]
22
+ configure(**kwargs) -> None
23
+ call_tool(name: str, args: dict) -> dict
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import fnmatch
29
+ import glob as _glob
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import signal as _signal
36
+ import socket
37
+ import subprocess
38
+ import tempfile
39
+ import threading
40
+ import time
41
+ import uuid
42
+ from datetime import datetime, timezone
43
+ from typing import Any, Callable, Dict, List, Optional, Tuple
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Optional dependency: the host vitals collector. Kept optional so the module
47
+ # stays importable under a bare interpreter.
48
+ # ---------------------------------------------------------------------------
49
+ try: # pragma: no cover - exercised implicitly by the environment
50
+ from core.vitals import get_host_vitals as _core_get_host_vitals # type: ignore
51
+ except Exception: # pragma: no cover
52
+ _core_get_host_vitals = None # type: ignore
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Configuration / module state
57
+ # ---------------------------------------------------------------------------
58
+
59
+ _DEFAULT_MAX_OUTPUT_CHARS = 50000
60
+ _DEFAULT_JOBS_DIR = os.path.join("~", ".cache", "antigravity-mesh", "jobs")
61
+
62
+ #: bash_exec never blocks a transport thread for longer than this.
63
+ MAX_TIMEOUT_SEC = 120
64
+ #: job_output caps its wait so it stays well inside the 28s gateway limit.
65
+ MAX_WAIT_MS = 20000
66
+ #: run_job refuses to start more than this many concurrent jobs.
67
+ MAX_RUNNING_JOBS = 32
68
+ #: Per-job output written to disk before a marker is appended and growth stops.
69
+ JOB_OUTPUT_CAP = 64 * 1024 * 1024
70
+ #: bash_exec spools its complete output to disk above this size.
71
+ SPOOL_THRESHOLD = 2 * 1024 * 1024
72
+ #: Hard ceiling for glob_find result sets.
73
+ GLOB_MAX_RESULTS = 10000
74
+
75
+ _SKIP_DIRS = frozenset((".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv"))
76
+
77
+ _WORKSPACE = os.path.realpath(os.getcwd())
78
+ _READ_ONLY = False
79
+ _WRITE_ROOTS: Optional[List[str]] = None
80
+ _JOBS_DIR = os.path.realpath(os.path.expanduser(_DEFAULT_JOBS_DIR))
81
+ _MAX_OUTPUT_CHARS = _DEFAULT_MAX_OUTPUT_CHARS
82
+
83
+ _META_LOCK = threading.RLock()
84
+ _JOBS: Dict[str, Dict[str, Any]] = {}
85
+
86
+
87
+ def _split_roots(value: Any) -> Optional[List[str]]:
88
+ """Normalise ``write_roots`` (list/tuple/pathsep string) into real paths."""
89
+ if value is None:
90
+ return None
91
+ if isinstance(value, str):
92
+ parts = [p for p in value.split(os.pathsep) if p.strip()]
93
+ if len(parts) == 1 and os.pathsep not in value:
94
+ # A single root with no separator: still accept it verbatim.
95
+ parts = [value]
96
+ elif isinstance(value, (list, tuple, set)):
97
+ parts = [str(p) for p in value if str(p).strip()]
98
+ else:
99
+ parts = [str(value)]
100
+ roots: List[str] = []
101
+ for part in parts:
102
+ if not str(part).strip():
103
+ continue
104
+ roots.append(os.path.realpath(os.path.expanduser(str(part).strip())))
105
+ return roots or None
106
+
107
+
108
+ def _parse_bool(value: Any) -> bool:
109
+ if isinstance(value, bool):
110
+ return value
111
+ if value is None:
112
+ return False
113
+ return str(value).strip().lower() in ("1", "true", "yes", "on", "y", "t")
114
+
115
+
116
+ def configure(**kwargs: Any) -> None:
117
+ """(Re)configure the module. All keys are optional.
118
+
119
+ Recognised keys: ``workspace``, ``read_only``, ``allow_write``,
120
+ ``write_roots``, ``jobs_dir``, ``max_output_chars``.
121
+ """
122
+ global _WORKSPACE, _READ_ONLY, _WRITE_ROOTS, _JOBS_DIR, _MAX_OUTPUT_CHARS
123
+
124
+ if kwargs.get("workspace"):
125
+ _WORKSPACE = os.path.realpath(os.path.expanduser(str(kwargs["workspace"])))
126
+ if "read_only" in kwargs and kwargs["read_only"] is not None:
127
+ _READ_ONLY = _parse_bool(kwargs["read_only"])
128
+ elif "allow_write" in kwargs and kwargs["allow_write"] is not None:
129
+ _READ_ONLY = not _parse_bool(kwargs["allow_write"])
130
+ if "write_roots" in kwargs:
131
+ _WRITE_ROOTS = _split_roots(kwargs["write_roots"])
132
+ if kwargs.get("jobs_dir"):
133
+ _JOBS_DIR = os.path.realpath(os.path.expanduser(str(kwargs["jobs_dir"])))
134
+ if kwargs.get("max_output_chars") is not None:
135
+ try:
136
+ value = int(kwargs["max_output_chars"])
137
+ if value > 0:
138
+ _MAX_OUTPUT_CHARS = value
139
+ except (TypeError, ValueError):
140
+ pass
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Small helpers
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def _now_iso() -> str:
148
+ return datetime.now(timezone.utc).isoformat()
149
+
150
+
151
+ def _sha256_hex(data: bytes) -> str:
152
+ return hashlib.sha256(data).hexdigest()
153
+
154
+
155
+ def _resolve(path: Any) -> str:
156
+ """expanduser -> relative to workspace -> realpath."""
157
+ raw = "" if path is None else str(path)
158
+ raw = raw.strip()
159
+ if not raw:
160
+ raw = _WORKSPACE
161
+ raw = os.path.expanduser(raw)
162
+ if not os.path.isabs(raw):
163
+ raw = os.path.join(_WORKSPACE, raw)
164
+ return os.path.realpath(raw)
165
+
166
+
167
+ def _within_write_roots(target: str) -> bool:
168
+ if _WRITE_ROOTS is None:
169
+ return True
170
+ candidate = os.path.realpath(target)
171
+ for root in _WRITE_ROOTS:
172
+ root = os.path.realpath(root)
173
+ if candidate == root:
174
+ return True
175
+ if candidate.startswith(root.rstrip(os.sep) + os.sep):
176
+ return True
177
+ return False
178
+
179
+
180
+ def _write_guard(target: str) -> Optional[str]:
181
+ """Return an error string when writing to *target* is not permitted."""
182
+ if _READ_ONLY:
183
+ return "server is in read-only mode"
184
+ if not _within_write_roots(target):
185
+ return "path is outside allowed write roots: %s" % target
186
+ return None
187
+
188
+
189
+ def _clamp_int(value: Any, low: int, high: int, default: int) -> int:
190
+ try:
191
+ result = int(value)
192
+ except (TypeError, ValueError):
193
+ return default
194
+ if result < low:
195
+ return low
196
+ if result > high:
197
+ return high
198
+ return result
199
+
200
+
201
+ def _max_chars(value: Any) -> int:
202
+ if value is None:
203
+ return _MAX_OUTPUT_CHARS
204
+ try:
205
+ result = int(value)
206
+ except (TypeError, ValueError):
207
+ return _MAX_OUTPUT_CHARS
208
+ if result < 1:
209
+ return 1
210
+ if result > 50 * 1024 * 1024:
211
+ return 50 * 1024 * 1024
212
+ return result
213
+
214
+
215
+ def _safe_int(value: Any, default: int = 0) -> int:
216
+ try:
217
+ return int(value)
218
+ except (TypeError, ValueError):
219
+ return default
220
+
221
+
222
+ def _sanitize_output(text: str) -> str:
223
+ """Strip ANSI escapes and non-printable control characters."""
224
+ if not text:
225
+ return ""
226
+ text = re.sub(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "", text)
227
+ return "".join(
228
+ ch for ch in text if ch in ("\n", "\r", "\t") or (ord(ch) >= 32 and ord(ch) != 127)
229
+ )
230
+
231
+
232
+ def _paginate(full: str, cursor: Any, max_chars: int) -> Tuple[str, bool, Optional[int]]:
233
+ """Return ``(chunk, truncated, next_cursor)`` for a character offset cursor."""
234
+ offset = max(0, _safe_int(cursor, 0))
235
+ total = len(full)
236
+ if offset >= total:
237
+ return "", False, None
238
+ end = offset + max_chars
239
+ chunk = full[offset:end]
240
+ if end < total:
241
+ return chunk, True, end
242
+ return chunk, False, None
243
+
244
+
245
+ def _atomic_write_bytes(target: str, data: bytes, mode: Optional[int] = None) -> None:
246
+ """Write *data* to *target* atomically (tmp file in the same dir + replace).
247
+
248
+ ``mkstemp`` creates the temporary file with mode 0600, so the mode has to be
249
+ set explicitly or an overwrite would silently strip a file's permissions
250
+ (e.g. make a launcher script non-executable). Precedence:
251
+ explicit ``mode`` > the mode of the file being replaced > 0644 for new files.
252
+ """
253
+ parent = os.path.dirname(target) or "."
254
+ existing_mode = None
255
+ if mode is None:
256
+ try:
257
+ existing_mode = os.stat(target).st_mode & 0o7777
258
+ except OSError:
259
+ existing_mode = None
260
+ fd, tmp = tempfile.mkstemp(prefix=".mcp-tmp-", dir=parent)
261
+ try:
262
+ with os.fdopen(fd, "wb") as handle:
263
+ handle.write(data)
264
+ handle.flush()
265
+ os.fsync(handle.fileno())
266
+ if mode is not None:
267
+ os.chmod(tmp, mode)
268
+ elif existing_mode is not None:
269
+ os.chmod(tmp, existing_mode)
270
+ else:
271
+ os.chmod(tmp, 0o644)
272
+ os.replace(tmp, target)
273
+ except Exception:
274
+ try:
275
+ os.unlink(tmp)
276
+ except OSError:
277
+ pass
278
+ raise
279
+
280
+
281
+ def _parse_mode(mode: Any) -> Optional[int]:
282
+ if mode is None:
283
+ return None
284
+ if isinstance(mode, bool):
285
+ raise ValueError("invalid mode")
286
+ if isinstance(mode, int):
287
+ return mode
288
+ text = str(mode).strip()
289
+ if not text:
290
+ return None
291
+ return int(text, 8)
292
+
293
+
294
+ def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
295
+ data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
296
+ _atomic_write_bytes(path, data)
297
+
298
+
299
+ def _read_json(path: str) -> Optional[Dict[str, Any]]:
300
+ try:
301
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
302
+ value = json.load(handle)
303
+ return value if isinstance(value, dict) else None
304
+ except Exception:
305
+ return None
306
+
307
+
308
+ def _fallback_host_vitals() -> Dict[str, Any]:
309
+ """Stdlib-only vitals collector used when core.vitals is unavailable."""
310
+ import platform
311
+
312
+ system = platform.system()
313
+ load = {"1m": 0.0, "5m": 0.0, "15m": 0.0}
314
+ try:
315
+ if hasattr(os, "getloadavg"):
316
+ la = os.getloadavg()
317
+ load = {"1m": round(la[0], 2), "5m": round(la[1], 2), "15m": round(la[2], 2)}
318
+ except Exception:
319
+ pass
320
+
321
+ total_mb = 0.0
322
+ free_mb = 0.0
323
+ try:
324
+ with open("/proc/meminfo", "r") as handle:
325
+ for line in handle:
326
+ if line.startswith("MemTotal:"):
327
+ total_mb = round(int(line.split()[1]) / 1024.0, 1)
328
+ elif line.startswith("MemAvailable:"):
329
+ free_mb = round(int(line.split()[1]) / 1024.0, 1)
330
+ except Exception:
331
+ pass
332
+
333
+ used_mb = max(0.0, round(total_mb - free_mb, 1))
334
+ used_pct = round((used_mb / total_mb) * 100.0, 1) if total_mb > 0 else 0.0
335
+ try:
336
+ du = shutil.disk_usage("C:\\" if system == "Windows" else "/")
337
+ disk = {
338
+ "total_gb": round(du.total / (1024 ** 3), 2),
339
+ "used_gb": round(du.used / (1024 ** 3), 2),
340
+ "free_gb": round(du.free / (1024 ** 3), 2),
341
+ "used_pct": round((du.used / du.total) * 100.0, 1) if du.total > 0 else 0.0,
342
+ }
343
+ except Exception:
344
+ disk = {"total_gb": 0.0, "used_gb": 0.0, "free_gb": 0.0, "used_pct": 0.0}
345
+
346
+ return {
347
+ "hostname": socket.gethostname(),
348
+ "os": system,
349
+ "cpu_load": load,
350
+ "ram": {"total_mb": total_mb, "used_mb": used_mb, "free_mb": free_mb, "used_pct": used_pct},
351
+ "disk": disk,
352
+ }
353
+
354
+
355
+ def _skill_path() -> str:
356
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skills", "orchestrator.md")
357
+
358
+
359
+ def _load_orchestration_skill() -> str:
360
+ try:
361
+ with open(_skill_path(), "r", encoding="utf-8") as handle:
362
+ content = handle.read()
363
+ if content.strip():
364
+ return content
365
+ except Exception:
366
+ pass
367
+ return (
368
+ "# Antigravity Mesh Orchestrator Skill (Standalone)\n"
369
+ "Mode: Local Standalone Server (antigravity_mesh).\n"
370
+ "All commands execute directly on this host through the Antigravity Mesh MCP tools.\n"
371
+ "Rules:\n"
372
+ "1. Never ask the user to run commands manually; use bash_exec.\n"
373
+ "2. Verify exit codes and stdout/stderr before continuing.\n"
374
+ "3. On non-zero exit code, halt and report the error details.\n"
375
+ )
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Output spooling
380
+ # ---------------------------------------------------------------------------
381
+
382
+ def _outputs_dir() -> str:
383
+ path = os.path.join(_JOBS_DIR, "outputs")
384
+ os.makedirs(path, exist_ok=True)
385
+ return path
386
+
387
+
388
+ def _spool_output(text: str) -> Optional[str]:
389
+ """Persist a large text blob and return its absolute path (best effort)."""
390
+ try:
391
+ path = os.path.join(_outputs_dir(), "%s.out" % uuid.uuid4().hex)
392
+ _atomic_write_bytes(path, text.encode("utf-8", "replace"))
393
+ return path
394
+ except Exception:
395
+ return None
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # Tool 1-3: legacy tools (kept as-is for compatibility)
400
+ # ---------------------------------------------------------------------------
401
+
402
+ def _tool_mesh_status(args: Dict[str, Any]) -> Dict[str, Any]:
403
+ """Report that this node is reachable, with hard evidence.
404
+
405
+ The answer exists because a model may otherwise claim the host is offline or
406
+ invent a reason. Every field here was just measured on the node: if this text
407
+ arrives at all, the agent and the tunnel are working by definition.
408
+ """
409
+ import datetime
410
+ import socket as _socket
411
+
412
+ now = datetime.datetime.now().astimezone()
413
+ uptime_s = None
414
+ try:
415
+ with open("/proc/uptime", "r") as handle:
416
+ uptime_s = float(handle.read().split()[0])
417
+ except Exception:
418
+ pass
419
+ try:
420
+ hostname = _socket.gethostname()
421
+ except Exception:
422
+ hostname = "unknown"
423
+
424
+ # The pid and the process start prove the agent is the one answering.
425
+ agent_pid = os.getpid()
426
+ payload = {
427
+ "status": "online",
428
+ "reachable": True,
429
+ "message": ("This node IS reachable: the request that produced this answer was executed "
430
+ "on the host by the mesh agent. If a previous attempt reported the node as "
431
+ "offline, that report was wrong - retry the operation instead of concluding "
432
+ "the service is down."),
433
+ "hostname": hostname,
434
+ "agent_pid": agent_pid,
435
+ "checked_at": now.isoformat(timespec="seconds"),
436
+ "node_utc_offset": now.strftime("%z"),
437
+ "workspace": _WORKSPACE,
438
+ "pid_namespace": os.uname().nodename if hasattr(os, "uname") else hostname,
439
+ }
440
+ if uptime_s is not None:
441
+ payload["host_uptime_seconds"] = int(uptime_s)
442
+ payload["host_uptime_human"] = "%dh %dm" % (int(uptime_s // 3600), int((uptime_s % 3600) // 60))
443
+ return payload
444
+
445
+
446
+ def _tool_system_info(args: Dict[str, Any]) -> Dict[str, Any]:
447
+ """A one-call summary of the host.
448
+
449
+ Exploratory agents spend several tool calls discovering the same basics (OS,
450
+ desktop, home, disks). Gemini's client allows only a handful of calls per
451
+ turn, so this answers them in one round trip.
452
+ """
453
+ import platform
454
+ import socket as _socket
455
+ import subprocess as _sp
456
+
457
+ def run(cmd: str) -> str:
458
+ try:
459
+ out = _sp.run(cmd, shell=True, capture_output=True, text=True, timeout=8)
460
+ return (out.stdout or out.stderr).strip()
461
+ except Exception:
462
+ return ""
463
+
464
+ info: Dict[str, Any] = {
465
+ "hostname": _socket.gethostname(),
466
+ "os": platform.platform(),
467
+ "kernel": platform.release(),
468
+ "python": platform.python_version(),
469
+ "user": os.environ.get("USER") or os.environ.get("USERNAME") or "",
470
+ "home": os.path.expanduser("~"),
471
+ "cwd": os.getcwd(),
472
+ "desktop": os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or "",
473
+ "session_type": os.environ.get("XDG_SESSION_TYPE", ""),
474
+ "shell": os.environ.get("SHELL", ""),
475
+ "disks": run("df -h --output=target,size,used,avail,pcent 2>/dev/null | head -8"),
476
+ "memory": run("free -h 2>/dev/null | head -3"),
477
+ "load": run("uptime"),
478
+ "top_processes": run("ps -eo comm,%mem --sort=-%mem 2>/dev/null | head -6"),
479
+ }
480
+ # Current wallpaper, the usual first question for a desktop node.
481
+ # Current wallpaper. KDE keeps the last applied value in the appletsrc as
482
+ # `Image=<path-or-dir>` (sometimes with a file:// prefix); GNOME uses
483
+ # gsettings. A directory means a wallpaper package, so resolve the image
484
+ # inside it the same way Plasma does.
485
+ wallpaper = ""
486
+ kde_cfg = os.path.expanduser("~/.config/plasma-org.kde.plasma.desktop-appletsrc")
487
+ if os.path.isfile(kde_cfg):
488
+ try:
489
+ with open(kde_cfg, "r", encoding="utf-8", errors="replace") as handle:
490
+ for line in handle:
491
+ if line.startswith("Image="):
492
+ wallpaper = line.strip()[len("Image="):]
493
+ except Exception:
494
+ wallpaper = ""
495
+ wallpaper = wallpaper.replace("file://", "")
496
+ if not wallpaper:
497
+ gnome = run("gsettings get org.gnome.desktop.background picture-uri 2>/dev/null")
498
+ if gnome and "file://" in gnome:
499
+ wallpaper = gnome.split("file://", 1)[1].strip().strip("'\"")
500
+ if wallpaper:
501
+ if os.path.isdir(wallpaper):
502
+ # A wallpaper package holds many resolutions and formats (png, jpg,
503
+ # webp, avif); pick the largest image inside it.
504
+ best, best_size = "", 0
505
+ for root, _dirs, files in os.walk(wallpaper):
506
+ for name in files:
507
+ if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
508
+ path = os.path.join(root, name)
509
+ try:
510
+ size = os.path.getsize(path)
511
+ except OSError:
512
+ continue
513
+ if size > best_size:
514
+ best, best_size = path, size
515
+ wallpaper = best or wallpaper
516
+ info["wallpaper"] = wallpaper
517
+ info["wallpaper_exists"] = os.path.isfile(wallpaper)
518
+ return info
519
+
520
+
521
+ def _tool_system_vitals(args: Dict[str, Any]) -> Any:
522
+ try:
523
+ if _core_get_host_vitals is not None:
524
+ return _core_get_host_vitals()
525
+ except Exception:
526
+ pass
527
+ return _fallback_host_vitals()
528
+
529
+
530
+ def _tool_get_orchestration_skill(args: Dict[str, Any]) -> Any:
531
+ return _load_orchestration_skill()
532
+
533
+
534
+ def _tool_list_dir(args: Dict[str, Any]) -> Dict[str, Any]:
535
+ try:
536
+ raw = args.get("path", "") or ""
537
+ target = _resolve(raw)
538
+ if not os.path.exists(target):
539
+ return {
540
+ "exit_code": 1,
541
+ "stdout": "",
542
+ "stderr": ("[TOOL ERROR] Path not found: %s -- this path does NOT exist. "
543
+ "Never report it as real; check the parent directory first." % target),
544
+ }
545
+ if os.path.isfile(target):
546
+ return _tool_read_file({"path": target})
547
+
548
+ entries = []
549
+ for entry in sorted(os.scandir(target), key=lambda x: (not x.is_dir(), x.name.lower())):
550
+ suffix = "/" if entry.is_dir() else ""
551
+ size = entry.stat().st_size if entry.is_file() else 0
552
+ entries.append(
553
+ "%s%s" % (entry.name, suffix)
554
+ + (" (%s bytes)" % size if entry.is_file() else "")
555
+ )
556
+ listing = "\n".join(entries) if entries else "(empty directory)"
557
+ return {
558
+ "exit_code": 0,
559
+ # [VERIFIED BY TOOL] tells the model this listing came from the real
560
+ # filesystem in this call: only such data may be reported as fact.
561
+ "stdout": "[VERIFIED BY TOOL] Directory: %s (%d items):\n%s" % (target, len(entries), listing),
562
+ "stderr": "",
563
+ }
564
+ except Exception as exc:
565
+ return {"error": "list_dir failed: %s" % exc}
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # Tool 4: bash_exec
570
+ # ---------------------------------------------------------------------------
571
+
572
+ def _run_bash(command: str, timeout: int) -> Tuple[str, str, int, float]:
573
+ started = time.time()
574
+ try:
575
+ proc = subprocess.run(
576
+ ["bash", "-c", command],
577
+ cwd=_WORKSPACE if os.path.isdir(_WORKSPACE) else None,
578
+ capture_output=True,
579
+ text=True,
580
+ timeout=timeout,
581
+ errors="replace",
582
+ )
583
+ return proc.stdout or "", proc.stderr or "", proc.returncode, round(time.time() - started, 3)
584
+ except subprocess.TimeoutExpired as exc:
585
+ out = exc.stdout or ""
586
+ err = exc.stderr or ""
587
+ if isinstance(out, bytes):
588
+ out = out.decode("utf-8", "replace")
589
+ if isinstance(err, bytes):
590
+ err = err.decode("utf-8", "replace")
591
+ err = (err + "\n" if err else "") + (
592
+ "Command timed out after %d seconds. Tip: for long background tasks use "
593
+ "'nohup ... > output.log 2>&1 &' or run_job." % timeout
594
+ )
595
+ return out, err, 124, round(time.time() - started, 3)
596
+ except Exception as exc:
597
+ return "", str(exc), 1, round(time.time() - started, 3)
598
+
599
+
600
+ def _tool_bash_exec(args: Dict[str, Any]) -> Dict[str, Any]:
601
+ command = args.get("command")
602
+ if not isinstance(command, str) or not command.strip():
603
+ return {"error": "command is required and must be a non-empty string"}
604
+ timeout = _clamp_int(args.get("timeout_sec"), 1, MAX_TIMEOUT_SEC, 25)
605
+ max_chars = _max_chars(args.get("max_chars"))
606
+ cursor = max(0, _safe_int(args.get("cursor"), 0))
607
+
608
+ stdout_raw, stderr_raw, exit_code, duration = _run_bash(command, timeout)
609
+ out = _sanitize_output(stdout_raw)
610
+ err = _sanitize_output(stderr_raw)
611
+
612
+ full = out + err
613
+ saved_to: Optional[str] = None
614
+ if len(full.encode("utf-8", "replace")) > SPOOL_THRESHOLD:
615
+ saved_to = _spool_output(full)
616
+
617
+ out_len = len(out)
618
+ offset = min(cursor, len(full))
619
+ end = offset + max_chars
620
+ if offset < out_len:
621
+ stdout_part = full[offset:min(end, out_len)]
622
+ else:
623
+ stdout_part = ""
624
+ if end > out_len:
625
+ stderr_part = full[max(offset, out_len):end]
626
+ else:
627
+ stderr_part = ""
628
+ next_cursor = end if end < len(full) else None
629
+
630
+ return {
631
+ "stdout": stdout_part,
632
+ "stderr": stderr_part,
633
+ "exit_code": exit_code,
634
+ "duration": duration,
635
+ "truncated": next_cursor is not None,
636
+ "next_cursor": next_cursor,
637
+ "saved_to": saved_to,
638
+ }
639
+
640
+
641
+ # ---------------------------------------------------------------------------
642
+ # Tool 5: read_file
643
+ # ---------------------------------------------------------------------------
644
+
645
+ def _tool_read_file(args: Dict[str, Any]) -> Dict[str, Any]:
646
+ try:
647
+ path = args.get("path")
648
+ if not isinstance(path, str) or not path.strip():
649
+ return {"error": "path is required"}
650
+ target = _resolve(path)
651
+ if not os.path.exists(target):
652
+ return {"error": "[TOOL ERROR] File not found: %s -- does not exist, do not report it as real." % target}
653
+ if os.path.isdir(target):
654
+ return _tool_list_dir({"path": target})
655
+
656
+ with open(target, "r", encoding="utf-8", errors="replace") as handle:
657
+ lines = handle.readlines()
658
+
659
+ start_line = max(1, _safe_int(args.get("start_line"), 1) or 1)
660
+ end_line = args.get("end_line")
661
+ end = min(len(lines), _safe_int(end_line, len(lines))) if end_line else len(lines)
662
+ selected = lines[start_line - 1:end]
663
+ width = max(4, len(str(end))) # keep the column stable for files >= 10000 lines
664
+ content = "".join(
665
+ "%*d | %s" % (width, idx + start_line, line) for idx, line in enumerate(selected)
666
+ )
667
+ header = "[VERIFIED BY TOOL] File: %s (lines %d-%d of %d total)\n%s\n" % (
668
+ target,
669
+ start_line,
670
+ end,
671
+ len(lines),
672
+ "-" * 60,
673
+ )
674
+ full = _sanitize_output(header + content)
675
+
676
+ max_chars = _max_chars(args.get("max_chars"))
677
+ cursor = max(0, _safe_int(args.get("cursor"), 0))
678
+ chunk, truncated, next_cursor = _paginate(full, cursor, max_chars)
679
+ # No marker is appended inside the page: the text must stay byte-exact so
680
+ # a client can stitch pages using next_cursor. Callers surface that value.
681
+
682
+ return {
683
+ "stdout": chunk,
684
+ "stderr": "",
685
+ "exit_code": 0,
686
+ "truncated": truncated,
687
+ "next_cursor": next_cursor,
688
+ }
689
+ except Exception as exc:
690
+ return {"error": "read_file failed: %s" % exc}
691
+
692
+
693
+ # ---------------------------------------------------------------------------
694
+ # Tool 6: write_file
695
+ # ---------------------------------------------------------------------------
696
+
697
+ def _tool_write_file(args: Dict[str, Any]) -> Dict[str, Any]:
698
+ try:
699
+ path = args.get("path")
700
+ if not isinstance(path, str) or not path.strip():
701
+ return {"error": "path is required"}
702
+ if "content" not in args or args.get("content") is None:
703
+ return {"error": "content is required"}
704
+ content = args.get("content")
705
+ if not isinstance(content, str):
706
+ content = str(content)
707
+
708
+ target = _resolve(path)
709
+ guard = _write_guard(target)
710
+ if guard:
711
+ return {"error": guard}
712
+
713
+ create_dirs = args.get("create_dirs", True)
714
+ parent = os.path.dirname(target) or "."
715
+ if create_dirs and parent and not os.path.isdir(parent):
716
+ os.makedirs(parent, exist_ok=True)
717
+ if not os.path.isdir(parent):
718
+ return {"error": "parent directory does not exist: %s" % parent}
719
+
720
+ try:
721
+ mode = _parse_mode(args.get("mode"))
722
+ except Exception:
723
+ return {"error": "invalid mode: %r" % (args.get("mode"),)}
724
+
725
+ data = content.encode("utf-8", "replace")
726
+ _atomic_write_bytes(target, data, mode=mode)
727
+ return {"ok": True, "path": target, "bytes": len(data), "sha256": _sha256_hex(data)}
728
+ except Exception as exc:
729
+ return {"error": "write_file failed: %s" % exc}
730
+
731
+
732
+ # ---------------------------------------------------------------------------
733
+ # Tool 7: edit_file
734
+ # ---------------------------------------------------------------------------
735
+
736
+ def _tool_edit_file(args: Dict[str, Any]) -> Dict[str, Any]:
737
+ try:
738
+ path = args.get("path")
739
+ if not isinstance(path, str) or not path.strip():
740
+ return {"error": "path is required"}
741
+ old_string = args.get("old_string")
742
+ if not isinstance(old_string, str) or old_string == "":
743
+ return {"error": "old_string is required and must be a non-empty string"}
744
+ new_string = args.get("new_string")
745
+ if not isinstance(new_string, str):
746
+ return {"error": "new_string is required and must be a string"}
747
+
748
+ target = _resolve(path)
749
+ guard = _write_guard(target)
750
+ if guard:
751
+ return {"error": guard}
752
+ if not os.path.isfile(target):
753
+ return {"error": "[TOOL ERROR] File not found: %s -- does not exist, do not report it as real." % target}
754
+
755
+ with open(target, "rb") as handle:
756
+ raw = handle.read()
757
+ current_sha = _sha256_hex(raw)
758
+
759
+ expected = args.get("expected_sha256")
760
+ if expected:
761
+ if str(expected).strip().lower() != current_sha:
762
+ return {"error": "file changed since read (sha256 mismatch)"}
763
+
764
+ # Byte-level replacement: decoding with errors="replace" would silently
765
+ # corrupt non-UTF-8 files (0xff -> U+FFFD). Matching the raw bytes keeps
766
+ # the edit surgical for any encoding while old/new stay valid UTF-8.
767
+ try:
768
+ old_bytes = old_string.encode("utf-8")
769
+ new_bytes = new_string.encode("utf-8")
770
+ except UnicodeEncodeError as exc:
771
+ return {"error": "old_string/new_string must be valid UTF-8 text: %s" % exc}
772
+ count = raw.count(old_bytes)
773
+ if count == 0:
774
+ return {"error": "old_string not found"}
775
+ replace_all = _parse_bool(args.get("replace_all"))
776
+ if count > 1 and not replace_all:
777
+ return {"error": "old_string is not unique (%d matches)" % count}
778
+
779
+ replacements = count if replace_all else 1
780
+ if replace_all:
781
+ data = raw.replace(old_bytes, new_bytes)
782
+ else:
783
+ data = raw.replace(old_bytes, new_bytes, 1)
784
+
785
+ _atomic_write_bytes(target, data)
786
+ return {
787
+ "ok": True,
788
+ "path": target,
789
+ "replacements": replacements,
790
+ "sha256": _sha256_hex(data),
791
+ }
792
+ except Exception as exc:
793
+ return {"error": "edit_file failed: %s" % exc}
794
+
795
+
796
+ # ---------------------------------------------------------------------------
797
+ # Tool 8: grep_search
798
+ # ---------------------------------------------------------------------------
799
+
800
+ def _is_probably_binary(path: str) -> bool:
801
+ try:
802
+ with open(path, "rb") as handle:
803
+ return b"\x00" in handle.read(4096)
804
+ except Exception:
805
+ return True
806
+
807
+
808
+ def _iter_files(base: str):
809
+ if os.path.isfile(base):
810
+ yield base
811
+ return
812
+ for root, dirs, files in os.walk(base):
813
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
814
+ for name in files:
815
+ yield os.path.join(root, name)
816
+
817
+
818
+ def _tool_grep_search(args: Dict[str, Any]) -> Dict[str, Any]:
819
+ try:
820
+ pattern = args.get("pattern")
821
+ if not isinstance(pattern, str) or pattern == "":
822
+ return {"error": "pattern is required and must be a non-empty string"}
823
+ base = _resolve(args.get("path") or ".")
824
+ if not os.path.exists(base):
825
+ return {"error": "Path not found: %s" % base}
826
+
827
+ limit = _clamp_int(args.get("limit"), 1, 1000, 200)
828
+ ignore_case = _parse_bool(args.get("ignore_case"))
829
+ fixed = _parse_bool(args.get("fixed"))
830
+ try:
831
+ context = max(0, min(20, _safe_int(args.get("context"), 0)))
832
+ except Exception:
833
+ context = 0
834
+ glob_filter = args.get("glob")
835
+ if glob_filter is not None and not isinstance(glob_filter, str):
836
+ return {"error": "glob must be a string"}
837
+
838
+ flags = re.IGNORECASE if ignore_case else 0
839
+ if fixed:
840
+ needle = pattern.lower() if ignore_case else pattern
841
+ else:
842
+ try:
843
+ regex = re.compile(pattern, flags)
844
+ except re.error as exc:
845
+ return {"error": "invalid regular expression: %s" % exc}
846
+
847
+ matches: List[Dict[str, Any]] = []
848
+ truncated = False
849
+ for file_path in _iter_files(base):
850
+ if glob_filter and not fnmatch.fnmatch(os.path.basename(file_path), glob_filter):
851
+ continue
852
+ if _is_probably_binary(file_path):
853
+ continue
854
+ try:
855
+ with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
856
+ lines = handle.read().splitlines()
857
+ except Exception:
858
+ continue
859
+
860
+ hit_lines = []
861
+ for idx, line in enumerate(lines):
862
+ found = (needle in (line.lower() if ignore_case else line)) if fixed else bool(regex.search(line))
863
+ if found:
864
+ hit_lines.append(idx)
865
+ if not hit_lines:
866
+ continue
867
+
868
+ emitted = set()
869
+ for idx in hit_lines:
870
+ first = max(0, idx - context)
871
+ last = min(len(lines) - 1, idx + context)
872
+ for lineno in range(first, last + 1):
873
+ if lineno in emitted:
874
+ continue
875
+ if len(matches) >= limit:
876
+ truncated = True
877
+ break
878
+ emitted.add(lineno)
879
+ matches.append(
880
+ {"path": os.path.realpath(file_path), "line": lineno + 1, "text": lines[lineno]}
881
+ )
882
+ if truncated:
883
+ break
884
+ if truncated:
885
+ break
886
+
887
+ return {"matches": matches, "count": len(matches), "truncated": truncated,
888
+ "note": "[VERIFIED BY TOOL] matches come from the real filesystem"}
889
+ except Exception as exc:
890
+ return {"error": "grep_search failed: %s" % exc}
891
+
892
+
893
+ # ---------------------------------------------------------------------------
894
+ # Tool 9: glob_find
895
+ # ---------------------------------------------------------------------------
896
+
897
+ def _skip_path(path: str) -> bool:
898
+ parts = path.split(os.sep)
899
+ return any(part in _SKIP_DIRS for part in parts)
900
+
901
+
902
+ def _tool_glob_find(args: Dict[str, Any]) -> Dict[str, Any]:
903
+ try:
904
+ pattern = args.get("pattern")
905
+ if not isinstance(pattern, str) or not pattern.strip():
906
+ return {"error": "pattern is required and must be a non-empty string"}
907
+ base = _resolve(args.get("path") or ".")
908
+ if not os.path.exists(base):
909
+ return {"error": "Path not found: %s" % base}
910
+
911
+ found: List[str] = []
912
+ if "/" in pattern or "**" in pattern:
913
+ # Patterns containing a path separator must be matched against the
914
+ # path relative to `base` (sub/*.py, */b.py, sub/b.py, **/b.py);
915
+ # matching a bare basename against them always returned nothing.
916
+ try:
917
+ found = _glob.glob(os.path.join(_glob.escape(base), pattern), recursive=True)
918
+ except Exception as exc:
919
+ return {"error": "glob_find failed: %s" % exc}
920
+ found = [p for p in found if os.path.isfile(p) and not _skip_path(p)]
921
+ else:
922
+ for root, dirs, files in os.walk(base):
923
+ dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
924
+ for name in files:
925
+ if fnmatch.fnmatch(name, pattern):
926
+ found.append(os.path.join(root, name))
927
+
928
+ # Deduplicate: glob.glob itself returns duplicates for patterns such as
929
+ # "**/**/*.py", which would otherwise be reported several times.
930
+ files = sorted({os.path.realpath(p) for p in found})
931
+ truncated = len(files) > GLOB_MAX_RESULTS
932
+ if truncated:
933
+ files = files[:GLOB_MAX_RESULTS]
934
+ return {"files": files, "count": len(files), "truncated": truncated,
935
+ "note": "[VERIFIED BY TOOL] paths come from the real filesystem"}
936
+ except Exception as exc:
937
+ return {"error": "glob_find failed: %s" % exc}
938
+
939
+
940
+ # ---------------------------------------------------------------------------
941
+ # Tools 10-13: background jobs
942
+ # ---------------------------------------------------------------------------
943
+
944
+ class _OutputBudget(object):
945
+ """Shared 64 MB write budget for one job's stdout+stderr streams."""
946
+
947
+ def __init__(self, cap: int = JOB_OUTPUT_CAP) -> None:
948
+ self.cap = cap
949
+ self.used = 0
950
+ self.notified = False
951
+ self.lock = threading.Lock()
952
+
953
+ def write(self, handle, chunk: bytes) -> None:
954
+ with self.lock:
955
+ remaining = self.cap - self.used
956
+ if remaining <= 0:
957
+ return
958
+ take = chunk[:remaining]
959
+ handle.write(take)
960
+ self.used += len(take)
961
+ if len(take) < len(chunk) and not self.notified:
962
+ handle.write(b"\n... [job output truncated at 64 MB] ...\n")
963
+ self.notified = True
964
+
965
+
966
+ def _jobs_root() -> str:
967
+ os.makedirs(_JOBS_DIR, exist_ok=True)
968
+ return _JOBS_DIR
969
+
970
+
971
+ def _job_dir(job_id: str) -> str:
972
+ return os.path.join(_JOBS_DIR, job_id)
973
+
974
+
975
+ def _meta_path(job_id: str) -> str:
976
+ return os.path.join(_job_dir(job_id), "meta.json")
977
+
978
+
979
+ def _read_meta(job_id: str) -> Optional[Dict[str, Any]]:
980
+ if not job_id or not re.match(r"^[A-Za-z0-9_.-]{1,128}$", job_id):
981
+ return None
982
+ return _read_json(_meta_path(job_id))
983
+
984
+
985
+ def _update_meta(job_id: str, **fields: Any) -> Optional[Dict[str, Any]]:
986
+ with _META_LOCK:
987
+ meta = _read_json(_meta_path(job_id))
988
+ if meta is None:
989
+ return None
990
+ meta.update(fields)
991
+ try:
992
+ _write_json_atomic(_meta_path(job_id), meta)
993
+ except Exception:
994
+ return meta
995
+ return meta
996
+
997
+
998
+ def _proc_start_time(pid: Any) -> Optional[int]:
999
+ """Start time of a process (field 22 of /proc/<pid>/stat), used to detect PID reuse."""
1000
+ try:
1001
+ with open("/proc/%d/stat" % int(pid), "rb") as handle:
1002
+ data = handle.read()
1003
+ rest = data.rsplit(b")", 1)[1].split()
1004
+ return int(rest[19])
1005
+ except Exception:
1006
+ return None
1007
+
1008
+
1009
+ def _pid_alive(pid: Any, start_time: Any = None) -> bool:
1010
+ try:
1011
+ pid_int = int(pid)
1012
+ except (TypeError, ValueError):
1013
+ return False
1014
+ if pid_int <= 0:
1015
+ return False
1016
+ # After a restart the in-memory entry is gone; without the start time a
1017
+ # recycled PID would keep a finished job looking "running" forever.
1018
+ if start_time is not None:
1019
+ current = _proc_start_time(pid_int)
1020
+ if current is None:
1021
+ return False
1022
+ try:
1023
+ if int(start_time) != current:
1024
+ return False
1025
+ except (TypeError, ValueError):
1026
+ pass
1027
+ try:
1028
+ os.kill(pid_int, 0)
1029
+ except ProcessLookupError:
1030
+ return False
1031
+ except PermissionError:
1032
+ return True
1033
+ except Exception:
1034
+ return False
1035
+ return True
1036
+
1037
+
1038
+ def _duration_from_meta(meta: Dict[str, Any]) -> Optional[float]:
1039
+ started = meta.get("started_at")
1040
+ finished = meta.get("finished_at")
1041
+ if not started:
1042
+ return meta.get("duration")
1043
+ try:
1044
+ start_ts = datetime.fromisoformat(str(started))
1045
+ except Exception:
1046
+ return meta.get("duration")
1047
+ if finished:
1048
+ try:
1049
+ end_ts = datetime.fromisoformat(str(finished))
1050
+ except Exception:
1051
+ end_ts = datetime.now(timezone.utc)
1052
+ else:
1053
+ end_ts = datetime.now(timezone.utc)
1054
+ try:
1055
+ return round((end_ts - start_ts).total_seconds(), 3)
1056
+ except Exception:
1057
+ return meta.get("duration")
1058
+
1059
+
1060
+ def _refresh_meta(job_id: str, meta: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
1061
+ """Bring a job's persisted status up to date. Never raises."""
1062
+ try:
1063
+ if meta is None:
1064
+ meta = _read_json(_meta_path(job_id))
1065
+ if meta is None:
1066
+ return None
1067
+ if meta.get("status") != "running":
1068
+ return meta
1069
+
1070
+ entry = _JOBS.get(job_id) or {}
1071
+ proc = entry.get("proc")
1072
+ if proc is not None:
1073
+ returncode = proc.poll()
1074
+ if returncode is None:
1075
+ return meta
1076
+ # The process is gone: let the output pumps drain their pipes so a
1077
+ # subsequent file read cannot miss the tail of the output.
1078
+ for thread in entry.get("readers", []):
1079
+ try:
1080
+ thread.join(timeout=0.5)
1081
+ except Exception:
1082
+ pass
1083
+ status = "killed" if meta.get("status") == "killed" else "done"
1084
+ return _update_meta(
1085
+ job_id,
1086
+ status=status,
1087
+ exit_code=returncode,
1088
+ duration=_duration_from_meta(meta),
1089
+ finished_at=meta.get("finished_at") or _now_iso(),
1090
+ )
1091
+
1092
+ if not _pid_alive(meta.get("pid"), meta.get("pid_start")):
1093
+ return _update_meta(
1094
+ job_id,
1095
+ status="done",
1096
+ duration=_duration_from_meta(meta),
1097
+ finished_at=meta.get("finished_at") or _now_iso(),
1098
+ )
1099
+ return meta
1100
+ except Exception:
1101
+ return meta
1102
+
1103
+
1104
+ def _all_metas() -> List[Dict[str, Any]]:
1105
+ metas: List[Dict[str, Any]] = []
1106
+ try:
1107
+ if not os.path.isdir(_JOBS_DIR):
1108
+ return metas
1109
+ for name in os.listdir(_JOBS_DIR):
1110
+ path = os.path.join(_JOBS_DIR, name, "meta.json")
1111
+ if not os.path.isfile(path):
1112
+ continue
1113
+ meta = _read_json(path)
1114
+ if meta is not None:
1115
+ meta.setdefault("job_id", name)
1116
+ metas.append(meta)
1117
+ except Exception:
1118
+ pass
1119
+ return metas
1120
+
1121
+
1122
+ def _running_jobs() -> List[Dict[str, Any]]:
1123
+ running = []
1124
+ for meta in _all_metas():
1125
+ refreshed = _refresh_meta(meta.get("job_id", ""), meta)
1126
+ if refreshed and refreshed.get("status") == "running":
1127
+ running.append(refreshed)
1128
+ return running
1129
+
1130
+
1131
+ def _pump(stream, path: str, budget: _OutputBudget) -> None:
1132
+ try:
1133
+ with open(path, "ab", buffering=0) as handle:
1134
+ while True:
1135
+ chunk = stream.read(65536)
1136
+ if not chunk:
1137
+ break
1138
+ budget.write(handle, chunk)
1139
+ except Exception:
1140
+ pass
1141
+ finally:
1142
+ try:
1143
+ stream.close()
1144
+ except Exception:
1145
+ pass
1146
+
1147
+
1148
+ def _reap(job_id: str, proc: "subprocess.Popen", started_mono: float) -> None:
1149
+ """Background thread: collect a finished job and persist its result."""
1150
+ try:
1151
+ returncode = proc.wait()
1152
+ except Exception:
1153
+ returncode = None
1154
+
1155
+ entry = _JOBS.get(job_id) or {}
1156
+ for thread in entry.get("readers", []):
1157
+ try:
1158
+ thread.join(timeout=5)
1159
+ except Exception:
1160
+ pass
1161
+
1162
+ with _META_LOCK:
1163
+ meta = _read_json(_meta_path(job_id))
1164
+ if meta is None:
1165
+ return
1166
+ if meta.get("status") == "killed":
1167
+ status = "killed"
1168
+ else:
1169
+ status = "done"
1170
+ meta.update(
1171
+ {
1172
+ "status": status,
1173
+ "exit_code": returncode,
1174
+ "duration": round(time.time() - started_mono, 3),
1175
+ "finished_at": _now_iso(),
1176
+ }
1177
+ )
1178
+ try:
1179
+ _write_json_atomic(_meta_path(job_id), meta)
1180
+ except Exception:
1181
+ pass
1182
+
1183
+
1184
+ def _tool_run_job(args: Dict[str, Any]) -> Dict[str, Any]:
1185
+ try:
1186
+ command = args.get("command")
1187
+ if not isinstance(command, str) or not command.strip():
1188
+ return {"error": "command is required and must be a non-empty string"}
1189
+
1190
+ if len(_running_jobs()) >= MAX_RUNNING_JOBS:
1191
+ return {"error": "too many running jobs (limit %d)" % MAX_RUNNING_JOBS}
1192
+
1193
+ cwd = None
1194
+ if args.get("cwd"):
1195
+ cwd = _resolve(args.get("cwd"))
1196
+ if not os.path.isdir(cwd):
1197
+ return {"error": "cwd is not a directory: %s" % cwd}
1198
+ if cwd is None:
1199
+ cwd = _WORKSPACE if os.path.isdir(_WORKSPACE) else None
1200
+
1201
+ job_id = uuid.uuid4().hex
1202
+ job_dir = _job_dir(job_id)
1203
+ os.makedirs(job_dir, exist_ok=True)
1204
+ stdout_path = os.path.join(job_dir, "stdout.log")
1205
+ stderr_path = os.path.join(job_dir, "stderr.log")
1206
+ for path in (stdout_path, stderr_path):
1207
+ with open(path, "ab"):
1208
+ pass
1209
+
1210
+ try:
1211
+ proc = subprocess.Popen(
1212
+ ["bash", "-c", command],
1213
+ cwd=cwd,
1214
+ stdin=subprocess.DEVNULL,
1215
+ stdout=subprocess.PIPE,
1216
+ stderr=subprocess.PIPE,
1217
+ bufsize=0,
1218
+ start_new_session=True,
1219
+ )
1220
+ except Exception as exc:
1221
+ return {"error": "failed to start job: %s" % exc}
1222
+
1223
+ started_at = _now_iso()
1224
+ budget = _OutputBudget()
1225
+ readers = [
1226
+ threading.Thread(target=_pump, args=(proc.stdout, stdout_path, budget), daemon=True),
1227
+ threading.Thread(target=_pump, args=(proc.stderr, stderr_path, budget), daemon=True),
1228
+ ]
1229
+ for thread in readers:
1230
+ thread.start()
1231
+ started_mono = time.time()
1232
+ _JOBS[job_id] = {
1233
+ "proc": proc,
1234
+ "readers": readers,
1235
+ "started_mono": started_mono,
1236
+ "pid_start": _proc_start_time(proc.pid),
1237
+ }
1238
+
1239
+ meta = {
1240
+ "job_id": job_id,
1241
+ "command": command,
1242
+ "pid": proc.pid,
1243
+ "pid_start": _proc_start_time(proc.pid),
1244
+ "cwd": cwd,
1245
+ "started_at": started_at,
1246
+ "status": "running",
1247
+ "exit_code": None,
1248
+ "duration": None,
1249
+ "finished_at": None,
1250
+ "stdout_path": stdout_path,
1251
+ "stderr_path": stderr_path,
1252
+ }
1253
+ try:
1254
+ _write_json_atomic(_meta_path(job_id), meta)
1255
+ except Exception as exc:
1256
+ _JOBS.pop(job_id, None)
1257
+ return {"error": "failed to persist job metadata: %s" % exc}
1258
+
1259
+ reaper = threading.Thread(target=_reap, args=(job_id, proc, started_mono), daemon=True)
1260
+ reaper.start()
1261
+ _JOBS[job_id]["reaper"] = reaper
1262
+
1263
+ return {
1264
+ "job_id": job_id,
1265
+ "pid": proc.pid,
1266
+ "pid_start": meta.get("pid_start"),
1267
+ "command": command,
1268
+ "started_at": started_at,
1269
+ }
1270
+ except Exception as exc:
1271
+ return {"error": "run_job failed: %s" % exc}
1272
+
1273
+
1274
+ def _wait_for_job(job_id: str, wait_ms: int) -> None:
1275
+ deadline = time.time() + (wait_ms / 1000.0)
1276
+ while True:
1277
+ meta = _refresh_meta(job_id)
1278
+ if meta is None or meta.get("status") != "running":
1279
+ return
1280
+ if time.time() >= deadline:
1281
+ return
1282
+ entry = _JOBS.get(job_id) or {}
1283
+ proc = entry.get("proc")
1284
+ remaining = max(0.0, deadline - time.time())
1285
+ if proc is not None:
1286
+ try:
1287
+ proc.wait(timeout=min(remaining, 0.2) or 0.01)
1288
+ except Exception:
1289
+ pass
1290
+ else:
1291
+ time.sleep(min(0.1, remaining or 0.01))
1292
+
1293
+
1294
+ def _tool_job_output(args: Dict[str, Any]) -> Dict[str, Any]:
1295
+ try:
1296
+ job_id = args.get("job_id")
1297
+ if not isinstance(job_id, str) or not job_id.strip():
1298
+ return {"error": "job_id is required"}
1299
+ job_id = job_id.strip()
1300
+ meta = _read_meta(job_id)
1301
+ if meta is None:
1302
+ return {"error": "job not found: %s" % job_id}
1303
+
1304
+ wait_ms = _clamp_int(args.get("wait_ms"), 0, MAX_WAIT_MS, 0)
1305
+ if wait_ms:
1306
+ _wait_for_job(job_id, wait_ms)
1307
+ meta = _refresh_meta(job_id) or meta
1308
+
1309
+ stdout_path = meta.get("stdout_path") or os.path.join(_job_dir(job_id), "stdout.log")
1310
+ stderr_path = meta.get("stderr_path") or os.path.join(_job_dir(job_id), "stderr.log")
1311
+
1312
+ def _read_text(path: str) -> str:
1313
+ try:
1314
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
1315
+ return _sanitize_output(handle.read())
1316
+ except Exception:
1317
+ return ""
1318
+
1319
+ stdout_full = _read_text(stdout_path)
1320
+ stderr_full = _read_text(stderr_path)
1321
+
1322
+ max_chars = _max_chars(args.get("max_chars"))
1323
+ cursor = max(0, _safe_int(args.get("cursor"), 0))
1324
+ stdout_part, truncated, next_cursor = _paginate(stdout_full, cursor, max_chars)
1325
+ stderr_part = stderr_full[:max_chars]
1326
+ stderr_truncated = len(stderr_full) > max_chars
1327
+
1328
+ return {
1329
+ "status": meta.get("status", "running"),
1330
+ "exit_code": meta.get("exit_code"),
1331
+ "stdout": stdout_part,
1332
+ "stderr": stderr_part,
1333
+ "next_cursor": next_cursor,
1334
+ "duration": meta.get("duration") if meta.get("duration") is not None else _duration_from_meta(meta),
1335
+ "truncated": bool(truncated or stderr_truncated),
1336
+ }
1337
+ except Exception as exc:
1338
+ return {"error": "job_output failed: %s" % exc}
1339
+
1340
+
1341
+ _SIGNALS = {
1342
+ "TERM": _signal.SIGTERM,
1343
+ "KILL": _signal.SIGKILL,
1344
+ "INT": _signal.SIGINT,
1345
+ "HUP": _signal.SIGHUP,
1346
+ "QUIT": _signal.SIGQUIT,
1347
+ }
1348
+
1349
+
1350
+ def _tool_job_kill(args: Dict[str, Any]) -> Dict[str, Any]:
1351
+ try:
1352
+ if _READ_ONLY:
1353
+ return {"error": "server is in read-only mode"}
1354
+ job_id = args.get("job_id")
1355
+ if not isinstance(job_id, str) or not job_id.strip():
1356
+ return {"error": "job_id is required"}
1357
+ job_id = job_id.strip()
1358
+ meta = _read_meta(job_id)
1359
+ if meta is None:
1360
+ return {"error": "job not found: %s" % job_id}
1361
+ meta = _refresh_meta(job_id) or meta
1362
+
1363
+ raw_signal = args.get("signal", "TERM")
1364
+ if isinstance(raw_signal, int) and not isinstance(raw_signal, bool):
1365
+ sig = raw_signal
1366
+ else:
1367
+ sig = _SIGNALS.get(str(raw_signal).strip().upper())
1368
+ if sig is None:
1369
+ return {"error": "unsupported signal: %r" % (raw_signal,)}
1370
+
1371
+ if meta.get("status") != "running":
1372
+ return {"ok": True, "status": meta.get("status", "done")}
1373
+
1374
+ pid = meta.get("pid")
1375
+ delivered = False
1376
+ try:
1377
+ os.killpg(os.getpgid(int(pid)), sig)
1378
+ delivered = True
1379
+ except Exception:
1380
+ try:
1381
+ os.kill(int(pid), sig)
1382
+ delivered = True
1383
+ except Exception as exc:
1384
+ return {"error": "failed to signal job %s: %s" % (job_id, exc)}
1385
+
1386
+ if delivered:
1387
+ _update_meta(job_id, status="killed", finished_at=_now_iso(), duration=_duration_from_meta(meta))
1388
+ return {"ok": True, "status": "killed"}
1389
+ except Exception as exc:
1390
+ return {"error": "job_kill failed: %s" % exc}
1391
+
1392
+
1393
+ def _tool_job_list(args: Dict[str, Any]) -> Dict[str, Any]:
1394
+ try:
1395
+ metas = []
1396
+ for meta in _all_metas():
1397
+ refreshed = _refresh_meta(meta.get("job_id", ""), meta) or meta
1398
+ metas.append(refreshed)
1399
+ metas.sort(key=lambda item: str(item.get("started_at") or ""), reverse=True)
1400
+ # The full history is unbounded (one entry per job ever started), and every
1401
+ # byte here lands in the model's context. Show the most recent few by
1402
+ # default and let the caller ask for more explicitly.
1403
+ try:
1404
+ limit = int(args.get("limit") or 20)
1405
+ except (TypeError, ValueError):
1406
+ limit = 20
1407
+ limit = max(1, min(limit, 50))
1408
+ jobs = []
1409
+ for meta in metas[:limit]:
1410
+ command = str(meta.get("command") or "")
1411
+ if len(command) > 80:
1412
+ command = command[:77] + "..."
1413
+ jobs.append({
1414
+ "job_id": meta.get("job_id"),
1415
+ "command": command,
1416
+ "status": meta.get("status", "running"),
1417
+ "exit_code": meta.get("exit_code"),
1418
+ "duration": meta.get("duration"),
1419
+ "started_at": meta.get("started_at"),
1420
+ })
1421
+ return {"jobs": jobs, "count": len(jobs), "total": len(metas),
1422
+ "note": ("showing the %d most recent of %d; pass limit=<n> for more"
1423
+ % (len(jobs), len(metas))) if len(metas) > len(jobs) else None}
1424
+ except Exception as exc:
1425
+ return {"error": "job_list failed: %s" % exc}
1426
+
1427
+
1428
+ # ---------------------------------------------------------------------------
1429
+ # Tool definitions (MCP schemas)
1430
+ # ---------------------------------------------------------------------------
1431
+
1432
+ def _schema(properties: Dict[str, Any], required: Optional[List[str]] = None) -> Dict[str, Any]:
1433
+ schema: Dict[str, Any] = {"type": "object", "properties": properties}
1434
+ if required:
1435
+ schema["required"] = required
1436
+ schema["additionalProperties"] = False
1437
+ return schema
1438
+
1439
+
1440
+ TOOLS: List[Dict[str, Any]] = [
1441
+ {
1442
+ "name": "mesh_status",
1443
+ "title": "Mesh Status",
1444
+ "description": ("Confirm this node is reachable. Call this first if you think the host is "
1445
+ "offline; it reports live evidence from the host itself."),
1446
+ "inputSchema": _schema({}),
1447
+ },
1448
+ {
1449
+ "name": "system_info",
1450
+ "title": "System Info",
1451
+ "description": ("One-call host summary: OS, desktop, user, home, disks, memory, load, "
1452
+ "top processes and the current wallpaper."),
1453
+ "inputSchema": _schema({}),
1454
+ },
1455
+ {
1456
+ "name": "system_vitals",
1457
+ "title": "System Vitals",
1458
+ "description": "Retrieve CPU, RAM and disk metrics of the local host.",
1459
+ "inputSchema": _schema({}),
1460
+ },
1461
+ {
1462
+ "name": "get_orchestration_skill",
1463
+ "title": "Get Orchestration Skill",
1464
+ "description": "Load the current Antigravity orchestration skill and rules.",
1465
+ "inputSchema": _schema({}),
1466
+ },
1467
+ {
1468
+ "name": "list_dir",
1469
+ "title": "List Directory",
1470
+ "description": "List files and directories at the given path (workspace by default).",
1471
+ "inputSchema": _schema({"path": {"type": "string", "description": "Directory or file path."}}),
1472
+ },
1473
+ {
1474
+ "name": "bash_exec",
1475
+ "title": "Execute Bash Command",
1476
+ "description": (
1477
+ "Execute a shell command on the local host. Output is paginated: when the result is "
1478
+ "cut, call again with cursor=next_cursor to continue (nothing is dropped). Large "
1479
+ "outputs (>256 KB) are spooled to a file returned in saved_to."
1480
+ ),
1481
+ "inputSchema": _schema(
1482
+ {
1483
+ "command": {"type": "string", "description": "Shell command to execute."},
1484
+ "timeout_sec": {"type": "integer", "description": "Timeout in seconds (1-120, default 25)."},
1485
+ "max_chars": {"type": "integer", "description": "Maximum characters per response."},
1486
+ "cursor": {"type": "integer", "description": "Character offset to continue from."},
1487
+ },
1488
+ ["command"],
1489
+ ),
1490
+ },
1491
+ {
1492
+ "name": "read_file",
1493
+ "title": "Read File",
1494
+ "description": (
1495
+ "Read a text file with %4d | line numbers, optionally restricted to a line range. "
1496
+ "Supports max_chars/cursor pagination."
1497
+ ),
1498
+ "inputSchema": _schema(
1499
+ {
1500
+ "path": {"type": "string", "description": "File path to read."},
1501
+ "start_line": {"type": "integer", "description": "First line (1-based)."},
1502
+ "end_line": {"type": "integer", "description": "Last line (inclusive)."},
1503
+ "max_chars": {"type": "integer", "description": "Maximum characters per response."},
1504
+ "cursor": {"type": "integer", "description": "Character offset to continue from."},
1505
+ },
1506
+ ["path"],
1507
+ ),
1508
+ },
1509
+ {
1510
+ "name": "write_file",
1511
+ "title": "Write File",
1512
+ "description": "Atomically create or overwrite a file (tmp file + os.replace).",
1513
+ "inputSchema": _schema(
1514
+ {
1515
+ "path": {"type": "string", "description": "File path to write."},
1516
+ "content": {"type": "string", "description": "Full file content."},
1517
+ "create_dirs": {"type": "boolean", "description": "Create parent directories (default true)."},
1518
+ "mode": {"type": "string", "description": "Octal file mode, e.g. \"0644\"."},
1519
+ },
1520
+ ["path", "content"],
1521
+ ),
1522
+ },
1523
+ {
1524
+ "name": "edit_file",
1525
+ "title": "Edit File",
1526
+ "description": (
1527
+ "Replace an exact substring in a file. old_string must match exactly once unless "
1528
+ "replace_all is set. Optional expected_sha256 guards against overwriting a file "
1529
+ "that changed since it was read."
1530
+ ),
1531
+ "inputSchema": _schema(
1532
+ {
1533
+ "path": {"type": "string", "description": "File path to edit."},
1534
+ "old_string": {"type": "string", "description": "Exact text to replace."},
1535
+ "new_string": {"type": "string", "description": "Replacement text."},
1536
+ "expected_sha256": {"type": "string", "description": "Expected current sha256 of the file."},
1537
+ "replace_all": {"type": "boolean", "description": "Replace every occurrence."},
1538
+ },
1539
+ ["path", "old_string", "new_string"],
1540
+ ),
1541
+ },
1542
+ {
1543
+ "name": "grep_search",
1544
+ "title": "Grep Search",
1545
+ "description": "Recursively search file contents; skips binary files and heavy directories.",
1546
+ "inputSchema": _schema(
1547
+ {
1548
+ "pattern": {"type": "string", "description": "Regular expression (or literal text with fixed)."},
1549
+ "path": {"type": "string", "description": "Root directory or file (default workspace)."},
1550
+ "glob": {"type": "string", "description": "File name filter, e.g. *.py."},
1551
+ "limit": {"type": "integer", "description": "Maximum matches (1-1000, default 200)."},
1552
+ "ignore_case": {"type": "boolean", "description": "Case-insensitive search."},
1553
+ "fixed": {"type": "boolean", "description": "Treat pattern as literal text."},
1554
+ "context": {"type": "integer", "description": "Context lines around each match."},
1555
+ },
1556
+ ["pattern"],
1557
+ ),
1558
+ },
1559
+ {
1560
+ "name": "glob_find",
1561
+ "title": "Glob Find",
1562
+ "description": "Find files by glob pattern (* and **).",
1563
+ "inputSchema": _schema(
1564
+ {
1565
+ "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.py."},
1566
+ "path": {"type": "string", "description": "Root directory (default workspace)."},
1567
+ },
1568
+ ["pattern"],
1569
+ ),
1570
+ },
1571
+ {
1572
+ "name": "run_job",
1573
+ "title": "Run Background Job",
1574
+ "description": "Start a command in the background; returns a job_id for job_output/job_kill.",
1575
+ "inputSchema": _schema(
1576
+ {
1577
+ "command": {"type": "string", "description": "Shell command to run in the background."},
1578
+ "cwd": {"type": "string", "description": "Working directory (default workspace)."},
1579
+ },
1580
+ ["command"],
1581
+ ),
1582
+ },
1583
+ {
1584
+ "name": "job_output",
1585
+ "title": "Job Output",
1586
+ "description": "Read a background job's output, optionally waiting up to 20 s for completion.",
1587
+ "inputSchema": _schema(
1588
+ {
1589
+ "job_id": {"type": "string", "description": "Job id returned by run_job."},
1590
+ "wait_ms": {"type": "integer", "description": "Milliseconds to wait (0-20000)."},
1591
+ "max_chars": {"type": "integer", "description": "Maximum stdout characters per response."},
1592
+ "cursor": {"type": "integer", "description": "stdout character offset to continue from."},
1593
+ },
1594
+ ["job_id"],
1595
+ ),
1596
+ },
1597
+ {
1598
+ "name": "job_kill",
1599
+ "title": "Job Kill",
1600
+ "description": "Terminate a running background job.",
1601
+ "inputSchema": _schema(
1602
+ {
1603
+ "job_id": {"type": "string", "description": "Job id returned by run_job."},
1604
+ "signal": {"type": "string", "description": "TERM (default), KILL, INT, HUP or QUIT."},
1605
+ },
1606
+ ["job_id"],
1607
+ ),
1608
+ },
1609
+ {
1610
+ "name": "job_list",
1611
+ "title": "Job List",
1612
+ "description": "List recent background jobs, newest first (20 by default, max 50).",
1613
+ "inputSchema": _schema({
1614
+ "limit": {"type": "integer", "description": "How many jobs to return (1-50, default 20)."},
1615
+ }),
1616
+ },
1617
+ ]
1618
+
1619
+
1620
+ # ---------------------------------------------------------------------------
1621
+ # Dispatch
1622
+ # ---------------------------------------------------------------------------
1623
+
1624
+ _HANDLERS: Dict[str, Callable[[Dict[str, Any]], Any]] = {
1625
+ "mesh_status": _tool_mesh_status,
1626
+ "system_info": _tool_system_info,
1627
+ "system_vitals": _tool_system_vitals,
1628
+ "get_orchestration_skill": _tool_get_orchestration_skill,
1629
+ "list_dir": _tool_list_dir,
1630
+ "bash_exec": _tool_bash_exec,
1631
+ "read_file": _tool_read_file,
1632
+ "write_file": _tool_write_file,
1633
+ "edit_file": _tool_edit_file,
1634
+ "grep_search": _tool_grep_search,
1635
+ "glob_find": _tool_glob_find,
1636
+ "run_job": _tool_run_job,
1637
+ "job_output": _tool_job_output,
1638
+ "job_kill": _tool_job_kill,
1639
+ "job_list": _tool_job_list,
1640
+ }
1641
+
1642
+
1643
+ def call_tool(name: str, args: Any = None) -> Any:
1644
+ """Dispatch a tool call. Never raises: any failure becomes ``{"error": ...}``."""
1645
+ try:
1646
+ handler = _HANDLERS.get(name) if isinstance(name, str) else None
1647
+ if handler is None:
1648
+ return {"error": "Unknown tool: %s" % (name,)}
1649
+ if args is None:
1650
+ args = {}
1651
+ if not isinstance(args, dict):
1652
+ return {"error": "arguments must be an object"}
1653
+ result = handler(args)
1654
+ if result is None:
1655
+ return {"error": "tool %s returned no result" % name}
1656
+ return result
1657
+ except Exception as exc: # pragma: no cover - defensive
1658
+ return {"error": "%s: %s" % (type(exc).__name__, exc)}