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 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
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
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=(",", ":")))