vanth 1.0.0__py3-none-win_amd64.whl
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.
- vanth/__init__.py +13 -0
- vanth/__main__.py +3 -0
- vanth/agent_events.py +26 -0
- vanth/agent_logger.py +60 -0
- vanth/cli.py +273 -0
- vanth/client.py +144 -0
- vanth/codex_bridge.py +154 -0
- vanth/daemon.py +451 -0
- vanth/migrations.py +214 -0
- vanth/monitor-bin/vanth-monitor.exe +0 -0
- vanth/monitor.py +101 -0
- vanth/opencode_bridge.py +94 -0
- vanth/paths.py +119 -0
- vanth/runner.py +142 -0
- vanth/runtime_info.py +133 -0
- vanth/server.py +1997 -0
- vanth-1.0.0.dist-info/METADATA +924 -0
- vanth-1.0.0.dist-info/RECORD +21 -0
- vanth-1.0.0.dist-info/WHEEL +4 -0
- vanth-1.0.0.dist-info/entry_points.txt +5 -0
- vanth-1.0.0.dist-info/licenses/LICENSE +201 -0
vanth/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
__all__ = ["__version__"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _package_version() -> str:
|
|
5
|
+
try:
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
return version("vanth")
|
|
9
|
+
except PackageNotFoundError: # source checkout, not installed
|
|
10
|
+
return "0.0.0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__version__ = _package_version()
|
vanth/__main__.py
ADDED
vanth/agent_events.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def agent_event(event_type: str, message: str | None = None, **data: object) -> None:
|
|
5
|
+
payload: dict[str, object] = {"type": event_type, "data": data}
|
|
6
|
+
if message is not None:
|
|
7
|
+
payload["message"] = message
|
|
8
|
+
print("AGENT_EVENT " + json.dumps(payload, separators=(",", ":")), flush=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def progress(
|
|
12
|
+
current: float,
|
|
13
|
+
total: float | None = None,
|
|
14
|
+
unit: str | None = None,
|
|
15
|
+
stage: str | None = None,
|
|
16
|
+
message: str | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
data: dict[str, object] = {"current": current}
|
|
19
|
+
if total is not None:
|
|
20
|
+
data["total"] = total
|
|
21
|
+
data["percent"] = round((current / total) * 100, 2) if total else 0
|
|
22
|
+
if unit is not None:
|
|
23
|
+
data["unit"] = unit
|
|
24
|
+
if stage is not None:
|
|
25
|
+
data["stage"] = stage
|
|
26
|
+
agent_event("progress", message, **data)
|
vanth/agent_logger.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Loguru-based structured logging for Vanth jobs.
|
|
2
|
+
|
|
3
|
+
`from vanth.agent_logger import logger` then `logger.info(...)`. Every record is
|
|
4
|
+
routed to stdout as an `AGENT_EVENT` line with a `log` type, so the daemon
|
|
5
|
+
persists it as a timestamped, level-aware structured event (visible in the exact
|
|
6
|
+
event table) instead of a bare text line.
|
|
7
|
+
|
|
8
|
+
Unlike a plain `print`, loguru gives you levels, timestamps, exception
|
|
9
|
+
capture, and the option to also mirror records to a file or stderr while keeping
|
|
10
|
+
the Vanth event stream clean.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from loguru import logger as _logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _vanth_sink(message: Any) -> None:
|
|
22
|
+
record = message.record
|
|
23
|
+
payload = {
|
|
24
|
+
"type": "log",
|
|
25
|
+
"level": record["level"].name.lower(),
|
|
26
|
+
"message": record["message"],
|
|
27
|
+
}
|
|
28
|
+
extra = _clean_extra(dict(record.get("extra") or {}))
|
|
29
|
+
if extra:
|
|
30
|
+
payload["data"] = extra
|
|
31
|
+
exception = record.get("exception")
|
|
32
|
+
if exception is not None and exception.type is not None:
|
|
33
|
+
payload["message"] = f"{payload['message']} :: {exception.type.__name__}: {exception.value}"
|
|
34
|
+
print("AGENT_EVENT " + _json(payload), flush=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _json(payload: dict[str, Any]) -> str:
|
|
38
|
+
import json
|
|
39
|
+
|
|
40
|
+
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# A module-level logger whose default sink emits AGENT_EVENT log lines. The
|
|
44
|
+
# original stderr default is removed so records do not double-print.
|
|
45
|
+
logger = _logger.bind(__name__="vanth")
|
|
46
|
+
logger.remove() # drop loguru's default stderr sink
|
|
47
|
+
logger.add(_vanth_sink, format="{message}", level="TRACE")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clean_extra(extra: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
return {key: value for key, value in extra.items() if key != "__name__"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def log_with_context(level: str, message: str, **context: Any) -> None:
|
|
55
|
+
"""Emit a log event with extra context carried in the event `data`."""
|
|
56
|
+
method = getattr(logger, level.lower(), logger.info)
|
|
57
|
+
method(message, **context)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["logger", "log_with_context"]
|
vanth/cli.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Human-facing CLI for the Vanth daemon.
|
|
2
|
+
|
|
3
|
+
Unlike the MCP tools (which are JSON request/response over stdio), these
|
|
4
|
+
commands are meant for a person at a terminal: ``vanth status``, ``vanth
|
|
5
|
+
doctor``, ``vanth restart``. They read the same daemon discovery metadata and
|
|
6
|
+
speak the same authenticated loopback HTTP, but print readable output and exit
|
|
7
|
+
with a meaningful status code (0 = healthy, 1 = problem, 2 = usage).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .client import VanthClient
|
|
23
|
+
from .paths import canonical_home
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _discovery(home: Path) -> dict[str, Any] | None:
|
|
27
|
+
try:
|
|
28
|
+
payload = json.loads((home / "daemon.json").read_text(encoding="utf-8"))
|
|
29
|
+
return payload if isinstance(payload, dict) else None
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _daemon_version() -> str:
|
|
35
|
+
"""Best-effort version of the installed package (not the daemon process)."""
|
|
36
|
+
try:
|
|
37
|
+
from . import __version__
|
|
38
|
+
|
|
39
|
+
return __version__
|
|
40
|
+
except Exception:
|
|
41
|
+
return "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _health(url: str, token: str) -> dict[str, Any] | None:
|
|
45
|
+
"""Return the /health payload, or None if the daemon is unreachable."""
|
|
46
|
+
try:
|
|
47
|
+
request = urllib.request.Request(
|
|
48
|
+
url + "/health", headers={"Authorization": f"Bearer {token}"}
|
|
49
|
+
)
|
|
50
|
+
with urllib.request.urlopen(request, timeout=2) as response:
|
|
51
|
+
return json.loads(response.read().decode())
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pid_alive(pid: int) -> bool:
|
|
57
|
+
"""Return whether a process with the given PID is running."""
|
|
58
|
+
if not pid:
|
|
59
|
+
return False
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
|
64
|
+
stdout=subprocess.PIPE,
|
|
65
|
+
stderr=subprocess.DEVNULL,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=2,
|
|
68
|
+
)
|
|
69
|
+
return str(pid) in result.stdout
|
|
70
|
+
except Exception:
|
|
71
|
+
return True # assume alive on probe failure
|
|
72
|
+
try:
|
|
73
|
+
os.kill(pid, 0)
|
|
74
|
+
return True
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_status(home: Path, *, json_out: bool = False) -> int:
|
|
80
|
+
disc = _discovery(home)
|
|
81
|
+
url = disc["url"] if disc else None
|
|
82
|
+
token_path = home / "token"
|
|
83
|
+
token = token_path.read_text(encoding="utf-8").strip() if token_path.exists() else ""
|
|
84
|
+
|
|
85
|
+
up = False
|
|
86
|
+
health = None
|
|
87
|
+
doctor = None
|
|
88
|
+
if url and token:
|
|
89
|
+
health = _health(url, token)
|
|
90
|
+
if health is not None:
|
|
91
|
+
up = True
|
|
92
|
+
try:
|
|
93
|
+
client = VanthClient(url, home)
|
|
94
|
+
doctor = client.get("/doctor")
|
|
95
|
+
except Exception:
|
|
96
|
+
doctor = None
|
|
97
|
+
|
|
98
|
+
if json_out:
|
|
99
|
+
payload = {
|
|
100
|
+
"up": up,
|
|
101
|
+
"url": url,
|
|
102
|
+
"pid": disc.get("pid") if disc else None,
|
|
103
|
+
"daemon_schema_version": disc.get("schema_version") if disc else None,
|
|
104
|
+
"started_at": disc.get("started_at") if disc else None,
|
|
105
|
+
"package_version": _daemon_version(),
|
|
106
|
+
"health": health,
|
|
107
|
+
"doctor": doctor,
|
|
108
|
+
}
|
|
109
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
110
|
+
return 0 if up else 1
|
|
111
|
+
|
|
112
|
+
running = []
|
|
113
|
+
if up:
|
|
114
|
+
try:
|
|
115
|
+
client = VanthClient(url, home)
|
|
116
|
+
running = client.get("/jobs", {"status": ["running"]}).get("jobs", [])
|
|
117
|
+
except Exception:
|
|
118
|
+
running = []
|
|
119
|
+
|
|
120
|
+
print(f"vanth daemon: {'UP' if up else 'DOWN'}")
|
|
121
|
+
print(f" home: {home}")
|
|
122
|
+
print(f" url: {url or '(no daemon.json - never started)'}")
|
|
123
|
+
if disc:
|
|
124
|
+
print(f" pid: {disc.get('pid')}")
|
|
125
|
+
print(f" schema: {disc.get('schema_version')}")
|
|
126
|
+
print(f" started: {disc.get('started_at')}")
|
|
127
|
+
print(f" package: {_daemon_version()}")
|
|
128
|
+
if doctor:
|
|
129
|
+
print(f" running jobs: {len(running)}")
|
|
130
|
+
for job in running[:10]:
|
|
131
|
+
print(f" - {job.get('job_id')} {job.get('name') or ''} ({job.get('status')})")
|
|
132
|
+
print(f" schema (db): {doctor.get('schema_version')}")
|
|
133
|
+
counts = doctor.get("delivery_counts") or {}
|
|
134
|
+
if counts:
|
|
135
|
+
print(f" deliveries: {counts}")
|
|
136
|
+
if doctor.get("warnings"):
|
|
137
|
+
print(" warnings:")
|
|
138
|
+
for warning in doctor["warnings"]:
|
|
139
|
+
print(f" - {warning.get('type')}: {warning}")
|
|
140
|
+
elif up:
|
|
141
|
+
print(" doctor: unreachable (auth/schema problem)")
|
|
142
|
+
return 0 if up else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_doctor(home: Path, *, json_out: bool = False) -> int:
|
|
146
|
+
client = VanthClient(home=home)
|
|
147
|
+
try:
|
|
148
|
+
client.ensure()
|
|
149
|
+
report = client.get("/doctor")
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
print(f"vanth doctor: failed to reach daemon: {exc}")
|
|
152
|
+
return 1
|
|
153
|
+
if json_out:
|
|
154
|
+
print(json.dumps(report, indent=2, default=str))
|
|
155
|
+
else:
|
|
156
|
+
ok = report.get("ok")
|
|
157
|
+
print(f"vanth doctor: {'OK' if ok else 'PROBLEM'}")
|
|
158
|
+
print(f" home: {report.get('home')}")
|
|
159
|
+
print(f" schema: {report.get('schema_version')}")
|
|
160
|
+
print(f" tables: {len(report.get('tables', []))}")
|
|
161
|
+
print(f" deliveries: {report.get('delivery_counts')}")
|
|
162
|
+
print(f" codex: {'available' if report.get('codex', {}).get('available') else 'MISSING'}")
|
|
163
|
+
print(f" opencode: {'available' if report.get('opencode', {}).get('available') else 'MISSING'}")
|
|
164
|
+
print(f" quick_check: {report.get('quick_check')}")
|
|
165
|
+
print(f" disk_free: {_fmt_bytes(report.get('disk_free_bytes', 0))}")
|
|
166
|
+
for warning in report.get("warnings", []):
|
|
167
|
+
print(f" WARNING: {warning}")
|
|
168
|
+
return 0 if report.get("ok") else 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fmt_bytes(n: int) -> str:
|
|
172
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
173
|
+
if n < 1024 or unit == "TiB":
|
|
174
|
+
return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
|
|
175
|
+
n /= 1024
|
|
176
|
+
return f"{n:.1f} TiB"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_restart(home: Path, *, json_out: bool = False) -> int:
|
|
180
|
+
"""Gracefully stop the daemon (if running) and start it again fresh.
|
|
181
|
+
|
|
182
|
+
In-flight jobs are owned by detached runner processes, so they survive the
|
|
183
|
+
daemon restart; the new daemon reconciles them on startup. This is the
|
|
184
|
+
reliable way to pick up a code/version update.
|
|
185
|
+
"""
|
|
186
|
+
disc = _discovery(home)
|
|
187
|
+
url = disc["url"] if disc else None
|
|
188
|
+
token_path = home / "token"
|
|
189
|
+
token = token_path.read_text(encoding="utf-8").strip() if token_path.exists() else ""
|
|
190
|
+
was_up = bool(url and token and _health(url, token) is not None)
|
|
191
|
+
old_pid = disc.get("pid") if disc else None
|
|
192
|
+
|
|
193
|
+
if was_up:
|
|
194
|
+
# Ask the daemon to shut down gracefully.
|
|
195
|
+
try:
|
|
196
|
+
request = urllib.request.Request(
|
|
197
|
+
url + "/shutdown",
|
|
198
|
+
data=b"{}",
|
|
199
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
200
|
+
method="POST",
|
|
201
|
+
)
|
|
202
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
203
|
+
response.read()
|
|
204
|
+
except urllib.error.HTTPError as exc:
|
|
205
|
+
print(f"vanth restart: warning: shutdown returned HTTP {exc.code}: {exc.read()!r}", file=sys.stderr)
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
print(f"vanth restart: warning: shutdown request failed: {exc}", file=sys.stderr)
|
|
208
|
+
# Wait until the old daemon is truly gone: port closed, discovery
|
|
209
|
+
# metadata removed, and the old process (if known) has exited so the
|
|
210
|
+
# home lock is released before the new daemon starts.
|
|
211
|
+
deadline = time.monotonic() + 15
|
|
212
|
+
while time.monotonic() < deadline:
|
|
213
|
+
health_down = _health(url, token) is None
|
|
214
|
+
metadata_gone = not (home / "daemon.json").exists()
|
|
215
|
+
pid_gone = True
|
|
216
|
+
if old_pid and sys.platform == "win32":
|
|
217
|
+
pid_gone = not _pid_alive(old_pid)
|
|
218
|
+
elif old_pid:
|
|
219
|
+
pid_gone = not _pid_alive(old_pid)
|
|
220
|
+
if health_down and metadata_gone and pid_gone:
|
|
221
|
+
break
|
|
222
|
+
time.sleep(0.2)
|
|
223
|
+
|
|
224
|
+
# Start fresh and verify a new process is actually serving.
|
|
225
|
+
client = VanthClient(home=home)
|
|
226
|
+
try:
|
|
227
|
+
# The old process may still be releasing its home lock; retry briefly.
|
|
228
|
+
last_error: Exception | None = None
|
|
229
|
+
doctor = None
|
|
230
|
+
for _ in range(20):
|
|
231
|
+
try:
|
|
232
|
+
client.ensure()
|
|
233
|
+
doctor = client.get("/doctor")
|
|
234
|
+
break
|
|
235
|
+
except Exception as exc: # noqa: BLE001 - retry transient lock races
|
|
236
|
+
last_error = exc
|
|
237
|
+
time.sleep(0.25)
|
|
238
|
+
if doctor is None:
|
|
239
|
+
raise last_error or RuntimeError("vanthd did not start")
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
if json_out:
|
|
242
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
243
|
+
else:
|
|
244
|
+
print(f"vanth restart: failed to start daemon: {exc}")
|
|
245
|
+
return 1
|
|
246
|
+
if json_out:
|
|
247
|
+
print(json.dumps({"ok": True, "restarted_from_running": was_up, "schema_version": doctor.get("schema_version")}))
|
|
248
|
+
else:
|
|
249
|
+
print(f"vanth restart: daemon {'restarted' if was_up else 'started'} (schema v{doctor.get('schema_version')})")
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv: list[str] | None = None) -> int:
|
|
254
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
255
|
+
home = canonical_home()
|
|
256
|
+
json_out = "--json" in argv
|
|
257
|
+
argv = [arg for arg in argv if arg != "--json"]
|
|
258
|
+
if not argv:
|
|
259
|
+
print(__doc__, file=sys.stderr)
|
|
260
|
+
return 2
|
|
261
|
+
command = argv[0]
|
|
262
|
+
if command == "status":
|
|
263
|
+
return cmd_status(home, json_out=json_out)
|
|
264
|
+
if command == "doctor":
|
|
265
|
+
return cmd_doctor(home, json_out=json_out)
|
|
266
|
+
if command == "restart":
|
|
267
|
+
return cmd_restart(home, json_out=json_out)
|
|
268
|
+
print(f"vanth: unknown command {command!r}", file=sys.stderr)
|
|
269
|
+
return 2
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
raise SystemExit(main())
|
vanth/client.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import secrets
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .paths import canonical_home, secure_home_permissions
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _default_daemon_url() -> str:
|
|
20
|
+
host = os.environ.get("VANTH_DAEMON_HOST", "127.0.0.1")
|
|
21
|
+
try:
|
|
22
|
+
port = int(os.environ.get("VANTH_DAEMON_PORT", "8765"))
|
|
23
|
+
except ValueError:
|
|
24
|
+
port = 8765
|
|
25
|
+
return f"http://{host}:{port}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def auth_token_path(home: str | os.PathLike[str] | None = None) -> str:
|
|
29
|
+
return os.fspath(canonical_home(home) / "token")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_auth_token(home: str | os.PathLike[str] | None = None) -> str:
|
|
33
|
+
path = auth_token_path(home)
|
|
34
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
35
|
+
created = False
|
|
36
|
+
try:
|
|
37
|
+
with open(path, "x", encoding="utf-8") as handle:
|
|
38
|
+
handle.write(secrets.token_urlsafe(32))
|
|
39
|
+
created = True
|
|
40
|
+
except FileExistsError:
|
|
41
|
+
pass
|
|
42
|
+
try:
|
|
43
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
44
|
+
except OSError:
|
|
45
|
+
pass
|
|
46
|
+
if created:
|
|
47
|
+
# Newly-created token: tighten the home ACL so a broad profile-level
|
|
48
|
+
# grant (e.g. a sandbox group) cannot read it.
|
|
49
|
+
secure_home_permissions(os.path.dirname(path))
|
|
50
|
+
with open(path, encoding="utf-8") as handle:
|
|
51
|
+
token = handle.read().strip()
|
|
52
|
+
if not token:
|
|
53
|
+
raise RuntimeError("Vanth authentication token is empty")
|
|
54
|
+
return token
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class VanthClient:
|
|
58
|
+
def __init__(self, url: str | None = None, home: str | os.PathLike[str] | None = None) -> None:
|
|
59
|
+
self.home = canonical_home(home)
|
|
60
|
+
self.url = self._resolve_url(url).rstrip("/")
|
|
61
|
+
self.token = ensure_auth_token(self.home)
|
|
62
|
+
|
|
63
|
+
def _resolve_url(self, url: str | None) -> str:
|
|
64
|
+
if url:
|
|
65
|
+
return url
|
|
66
|
+
env_url = os.environ.get("VANTH_DAEMON_URL")
|
|
67
|
+
if env_url:
|
|
68
|
+
return env_url
|
|
69
|
+
discovered = self._discover_url()
|
|
70
|
+
if discovered:
|
|
71
|
+
return discovered
|
|
72
|
+
host = os.environ.get("VANTH_DAEMON_HOST", "127.0.0.1")
|
|
73
|
+
try:
|
|
74
|
+
port = int(os.environ.get("VANTH_DAEMON_PORT", "8765"))
|
|
75
|
+
except ValueError:
|
|
76
|
+
port = 8765
|
|
77
|
+
return f"http://{host}:{port}"
|
|
78
|
+
|
|
79
|
+
def _discover_url(self) -> str | None:
|
|
80
|
+
try:
|
|
81
|
+
payload = json.loads((self.home / "daemon.json").read_text(encoding="utf-8"))
|
|
82
|
+
return payload.get("url")
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _ready(self) -> bool:
|
|
87
|
+
payload = self.get("/doctor")
|
|
88
|
+
return (
|
|
89
|
+
isinstance(payload, dict)
|
|
90
|
+
and payload.get("result") != "error"
|
|
91
|
+
and payload.get("schema_version") is not None
|
|
92
|
+
and Path(str(payload.get("home", ""))).expanduser().resolve() == self.home
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def ensure(self) -> None:
|
|
96
|
+
try:
|
|
97
|
+
if self._ready():
|
|
98
|
+
return
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
subprocess.Popen(
|
|
102
|
+
[sys.executable, "-m", "vanth.daemon"],
|
|
103
|
+
stdin=subprocess.DEVNULL,
|
|
104
|
+
stdout=subprocess.DEVNULL,
|
|
105
|
+
stderr=subprocess.DEVNULL,
|
|
106
|
+
env=os.environ.copy(),
|
|
107
|
+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if sys.platform == "win32" else 0,
|
|
108
|
+
)
|
|
109
|
+
deadline = time.monotonic() + 5
|
|
110
|
+
while time.monotonic() < deadline:
|
|
111
|
+
try:
|
|
112
|
+
if self._ready():
|
|
113
|
+
return
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
time.sleep(0.1)
|
|
117
|
+
raise RuntimeError("vanthd did not start")
|
|
118
|
+
|
|
119
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
120
|
+
url = self.url + path
|
|
121
|
+
if params:
|
|
122
|
+
clean = {key: value for key, value in params.items() if value is not None}
|
|
123
|
+
if clean:
|
|
124
|
+
url += "?" + urllib.parse.urlencode(clean, doseq=True)
|
|
125
|
+
try:
|
|
126
|
+
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {self.token}"})
|
|
127
|
+
with urllib.request.urlopen(request, timeout=None) as response:
|
|
128
|
+
return json.loads(response.read().decode())
|
|
129
|
+
except urllib.error.HTTPError as exc:
|
|
130
|
+
return json.loads(exc.read().decode())
|
|
131
|
+
|
|
132
|
+
def post(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
133
|
+
data = json.dumps(payload or {}).encode()
|
|
134
|
+
request = urllib.request.Request(
|
|
135
|
+
self.url + path,
|
|
136
|
+
data=data,
|
|
137
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.token}"},
|
|
138
|
+
method="POST",
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(request, timeout=None) as response:
|
|
142
|
+
return json.loads(response.read().decode())
|
|
143
|
+
except urllib.error.HTTPError as exc:
|
|
144
|
+
return json.loads(exc.read().decode())
|
vanth/codex_bridge.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import queue
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CodexBridgeError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_codex_command() -> list[str]:
|
|
19
|
+
configured = os.environ.get("VANTH_CODEX_BIN")
|
|
20
|
+
if configured:
|
|
21
|
+
return [configured]
|
|
22
|
+
win_default = Path(r"C:\codex\codex.exe")
|
|
23
|
+
if sys.platform == "win32" and win_default.exists():
|
|
24
|
+
return [str(win_default)]
|
|
25
|
+
return ["codex"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _command_argv(command: Any) -> list[str]:
|
|
29
|
+
if command is None:
|
|
30
|
+
return _default_codex_command()
|
|
31
|
+
if isinstance(command, list):
|
|
32
|
+
return [str(part) for part in command]
|
|
33
|
+
if isinstance(command, str):
|
|
34
|
+
return [command]
|
|
35
|
+
raise CodexBridgeError("codex_command must be a string path or argv list")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reader(stream, out: queue.Queue[str]) -> None:
|
|
39
|
+
while True:
|
|
40
|
+
line = stream.readline()
|
|
41
|
+
if not line:
|
|
42
|
+
return
|
|
43
|
+
out.put(line.rstrip("\r\n"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _CodexAppServer:
|
|
47
|
+
def __init__(self, command: Any, timeout_seconds: int) -> None:
|
|
48
|
+
argv = _command_argv(command) + ["app-server", "--listen", "stdio://", "--analytics-default-enabled"]
|
|
49
|
+
self.deadline = time.monotonic() + timeout_seconds
|
|
50
|
+
self.stdout: queue.Queue[str] = queue.Queue()
|
|
51
|
+
self.stderr: queue.Queue[str] = queue.Queue()
|
|
52
|
+
self.stderr_tail: list[str] = []
|
|
53
|
+
self.proc = subprocess.Popen(
|
|
54
|
+
argv,
|
|
55
|
+
stdin=subprocess.PIPE,
|
|
56
|
+
stdout=subprocess.PIPE,
|
|
57
|
+
stderr=subprocess.PIPE,
|
|
58
|
+
text=True,
|
|
59
|
+
bufsize=1,
|
|
60
|
+
)
|
|
61
|
+
assert self.proc.stdout is not None
|
|
62
|
+
assert self.proc.stderr is not None
|
|
63
|
+
threading.Thread(target=_reader, args=(self.proc.stdout, self.stdout), daemon=True).start()
|
|
64
|
+
threading.Thread(target=_reader, args=(self.proc.stderr, self.stderr), daemon=True).start()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
if self.proc.poll() is not None:
|
|
68
|
+
return
|
|
69
|
+
self.proc.terminate()
|
|
70
|
+
try:
|
|
71
|
+
self.proc.wait(timeout=2)
|
|
72
|
+
except subprocess.TimeoutExpired:
|
|
73
|
+
self.proc.kill()
|
|
74
|
+
self.proc.wait(timeout=2)
|
|
75
|
+
|
|
76
|
+
def send(self, request_id: int, method: str, params: dict[str, Any]) -> None:
|
|
77
|
+
if self.proc.stdin is None:
|
|
78
|
+
raise CodexBridgeError("codex app-server stdin closed")
|
|
79
|
+
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + "\n")
|
|
80
|
+
self.proc.stdin.flush()
|
|
81
|
+
|
|
82
|
+
def response(self, request_id: int) -> dict[str, Any]:
|
|
83
|
+
while True:
|
|
84
|
+
while not self.stderr.empty():
|
|
85
|
+
self.stderr_tail.append(self.stderr.get())
|
|
86
|
+
self.stderr_tail = self.stderr_tail[-5:]
|
|
87
|
+
remaining = self.deadline - time.monotonic()
|
|
88
|
+
if remaining <= 0:
|
|
89
|
+
raise CodexBridgeError(self._error("timed out waiting for codex app-server"))
|
|
90
|
+
try:
|
|
91
|
+
line = self.stdout.get(timeout=min(0.1, remaining))
|
|
92
|
+
except queue.Empty:
|
|
93
|
+
if self.proc.poll() is not None:
|
|
94
|
+
raise CodexBridgeError(self._error(f"codex app-server exited with {self.proc.returncode}"))
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
message = json.loads(line)
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
continue
|
|
100
|
+
if message.get("id") != request_id:
|
|
101
|
+
continue
|
|
102
|
+
if "error" in message:
|
|
103
|
+
raise CodexBridgeError(self._error(message["error"].get("message", "codex app-server error")))
|
|
104
|
+
return message.get("result", {})
|
|
105
|
+
|
|
106
|
+
def _error(self, message: str) -> str:
|
|
107
|
+
if not self.stderr_tail:
|
|
108
|
+
return message
|
|
109
|
+
return f"{message}: {' | '.join(self.stderr_tail)}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def send_message_to_thread(
|
|
113
|
+
thread_id: str,
|
|
114
|
+
prompt: str,
|
|
115
|
+
*,
|
|
116
|
+
codex_command: Any = None,
|
|
117
|
+
timeout_seconds: int = 30,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
server = _CodexAppServer(codex_command, timeout_seconds)
|
|
120
|
+
try:
|
|
121
|
+
server.send(
|
|
122
|
+
1,
|
|
123
|
+
"initialize",
|
|
124
|
+
{"clientInfo": {"name": "vanth", "version": "0"}, "capabilities": {"experimentalApi": True}},
|
|
125
|
+
)
|
|
126
|
+
server.response(1)
|
|
127
|
+
server.send(2, "thread/resume", {"threadId": thread_id, "excludeTurns": True})
|
|
128
|
+
server.response(2)
|
|
129
|
+
server.send(3, "turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": prompt}]})
|
|
130
|
+
return server.response(3)
|
|
131
|
+
finally:
|
|
132
|
+
server.close()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def send_delivery_to_codex(payload: dict[str, Any]) -> dict[str, Any]:
|
|
136
|
+
target = payload.get("target") or {}
|
|
137
|
+
thread_id = target.get("thread_id") or target.get("threadId")
|
|
138
|
+
prompt = payload.get("prompt")
|
|
139
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
140
|
+
raise CodexBridgeError("codex_thread target requires thread_id")
|
|
141
|
+
if not isinstance(prompt, str) or not prompt:
|
|
142
|
+
raise CodexBridgeError("delivery payload requires prompt")
|
|
143
|
+
return send_message_to_thread(
|
|
144
|
+
thread_id,
|
|
145
|
+
prompt,
|
|
146
|
+
codex_command=target.get("codex_command"),
|
|
147
|
+
timeout_seconds=int(target.get("timeout_seconds", 30)),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main() -> None:
|
|
152
|
+
payload = json.load(sys.stdin)
|
|
153
|
+
result = send_delivery_to_codex(payload)
|
|
154
|
+
print(json.dumps(result, separators=(",", ":")))
|