codex-channel-molecule is the codex-side counterpart to hermes-channel-molecule. It long-polls the molecule platform inbox via molecule_runtime.a2a_tools.tool_wait_for_message, runs `codex exec --resume <session>` per inbound message, captures the assistant reply from stdout, and routes it back through send_message_to_user (canvas chat) or delegate_task (peer agent), then acks the inbox row. Per chat thread (one canvas-user thread or one peer-workspace thread) gets its own codex session_id, persisted to disk so daemon restarts keep conversation context. Reply-routing failures skip the inbox_pop ack so the platform's at-least-once delivery re-surfaces the row on the next poll. This daemon is the operator-unblock until openai/codex#17543 lands — once codex itself accepts MCP custom notifications as Op::UserInput through the wired-in MCP server, this daemon becomes redundant. The README's deprecation-path section calls that out so future operators know when to switch off. Tests cover the dispatch loop with fake tools (8 tests asserting exact contracts: canvas vs peer routing, session continuity, persistence across restarts, timeout sentinel handling, at-least-once on reply failure, exit-code surfacing, A2A multipart text). The codex_runner tests are real-subprocess (fake codex script spawned via asyncio.create_subprocess_exec) so the boot path matches production — no in-process mocking of the spawn boundary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.3 KiB
Python
119 lines
3.3 KiB
Python
"""Console-script entry point — ``codex-channel-molecule``.
|
|
|
|
Validates the env-var contract (mirrors hermes-channel-molecule), wires
|
|
up signal handling, and runs the bridge loop until interrupted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
|
|
from .bridge import run_bridge
|
|
from .codex_runner import CodexRunner
|
|
|
|
_REQUIRED_ENV = ("WORKSPACE_ID", "PLATFORM_URL", "MOLECULE_WORKSPACE_TOKEN")
|
|
|
|
|
|
def _check_env() -> list[str]:
|
|
missing = [name for name in _REQUIRED_ENV if not os.environ.get(name, "").strip()]
|
|
return missing
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
prog="codex-channel-molecule",
|
|
description=(
|
|
"Bridge daemon — long-polls the molecule platform inbox and "
|
|
"runs `codex exec --resume <session>` per inbound message. "
|
|
"Reply routes back via send_message_to_user (canvas) or "
|
|
"delegate_task (peer). Per-thread codex session continuity "
|
|
"is persisted to disk."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--codex-bin",
|
|
default="codex",
|
|
help="codex CLI binary name or absolute path (default: codex).",
|
|
)
|
|
parser.add_argument(
|
|
"--turn-timeout-secs",
|
|
type=float,
|
|
default=600.0,
|
|
help="Per-turn ceiling on `codex exec` runtime (default: 600).",
|
|
)
|
|
parser.add_argument(
|
|
"--log-level",
|
|
default="INFO",
|
|
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
|
|
help="Logging level (default: INFO).",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
async def _async_main(args: argparse.Namespace) -> int:
|
|
runner = CodexRunner(
|
|
codex_bin=args.codex_bin,
|
|
timeout_secs=args.turn_timeout_secs,
|
|
)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
stop_event = asyncio.Event()
|
|
|
|
def _request_stop() -> None:
|
|
stop_event.set()
|
|
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
loop.add_signal_handler(sig, _request_stop)
|
|
|
|
bridge_task = asyncio.create_task(run_bridge(runner=runner))
|
|
stop_task = asyncio.create_task(stop_event.wait())
|
|
|
|
done, pending = await asyncio.wait(
|
|
{bridge_task, stop_task},
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
for t in pending:
|
|
t.cancel()
|
|
for t in pending:
|
|
try:
|
|
await t
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
|
|
logging.basicConfig(
|
|
level=args.log_level,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
|
|
missing = _check_env()
|
|
if missing:
|
|
sys.stderr.write(
|
|
"codex-channel-molecule: required env vars not set: "
|
|
+ ", ".join(missing)
|
|
+ "\n WORKSPACE_ID — UUID of the workspace this daemon represents\n"
|
|
+ " PLATFORM_URL — https://<your-tenant>.moleculesai.app\n"
|
|
+ " MOLECULE_WORKSPACE_TOKEN — bearer token from the platform's\n"
|
|
+ " External Connect modal for that workspace\n"
|
|
)
|
|
return 2
|
|
|
|
try:
|
|
return asyncio.run(_async_main(args))
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|