Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,38 @@ source .venv/bin/activate
pip install -e .
```

## GitHub Copilot via `copilot-proxy`

ArgusBot can route every `codex exec` call through a local `copilot-proxy` checkout, so main/reviewer/planner/BTW runs can use GitHub Copilot-backed quota instead of OpenAI API billing.

Simplest setup:

```bash
argusbot init
```

During `argusbot init` / `argusbot-setup`, ArgusBot will:

- auto-detect an existing proxy checkout in `~/copilot-proxy`, `~/copilot-codex-proxy`, or `~/.argusbot/tools/copilot-proxy`
- if you select the `copilot` preset (or explicitly enable Copilot proxy), offer to auto-install the proxy into `~/.argusbot/tools/copilot-proxy`

Direct CLI example:

```bash
argusbot-run \
--copilot-proxy \
--main-model gpt-5.4 \
--reviewer-model gpt-5.4 \
--plan-model gpt-5.4 \
"实现功能并跑完验证"
```

Notes:

- `--copilot-proxy-dir` is only needed when your proxy checkout lives outside the auto-detected locations above.
- When enabled, ArgusBot auto-starts `proxy.mjs` if needed and injects Codex provider overrides per run, so you do not have to rewrite your global `~/.codex/config.toml`.
- Prefer Copilot-supported models such as `gpt-5.4`, `gpt-5.2`, `gpt-5.1`, `gpt-4o`, `claude-sonnet-4.6`, `claude-opus-4.6`, or `gemini-3-pro-preview`.

## One-word operator workflow (`argusbot`)

Run:
Expand Down Expand Up @@ -162,6 +194,7 @@ Common options:
- `--session-id <id>`: continue an existing Codex session
- `--main-model` / `--reviewer-model`: set model(s)
- `--planner-model`: override the manager/planner model (defaults to reviewer settings when omitted)
- `--copilot-proxy [--copilot-proxy-port 18080] [--copilot-proxy-dir /custom/path]`: route Codex through local `copilot-proxy`
- `python -m codex_autoloop.model_catalog`: list common models and presets
- `--yolo`: pass dangerous no-sandbox mode to Codex
- `--full-auto`: pass full-auto mode to Codex
Expand Down Expand Up @@ -491,6 +524,7 @@ python -m codex_autoloop.model_catalog
Current presets:

- `quality`: `main=gpt-5.4/high`, `reviewer=gpt-5.4/high`
- `copilot`: `main=gpt-5.4/high`, `reviewer=gpt-5.4/high`
- `codex52-xhigh`: `main=gpt-5.2-codex/xhigh`, `reviewer=gpt-5.2-codex/xhigh`
- `quality-xhigh`: `main=gpt-5.4/xhigh`, `reviewer=gpt-5.4/xhigh`
- `balanced`: `main=gpt-5.3-codex/high`, `reviewer=gpt-5.1-codex/medium`
Expand Down
14 changes: 11 additions & 3 deletions codex_autoloop/apps/cli_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
TerminalEventSink,
)
from ..btw_agent import BtwAgent, BtwConfig
from ..codex_runner import CodexRunner
from ..copilot_proxy import build_codex_runner, config_from_args, format_proxy_summary
from ..core.engine import LoopConfig, LoopEngine
from ..core.state_store import LoopStateStore
from ..dashboard import DashboardServer, DashboardStore
Expand All @@ -40,6 +40,7 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]:
dashboard_server: DashboardServer | None = None
event_sink: CompositeEventSink | None = None
control_channels: list[object] = []
copilot_proxy = config_from_args(args)

operator_messages_file = resolve_operator_messages_file(
explicit_path=args.operator_messages_file,
Expand Down Expand Up @@ -92,6 +93,9 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]:
)
sinks.append(DashboardEventSink(dashboard_store))

if copilot_proxy.enabled:
print(f"Copilot proxy mode: {format_proxy_summary(copilot_proxy)}", file=sys.stderr)

telegram_notifier: TelegramNotifier | None = None
raw_chat_id = (args.telegram_chat_id or "").strip()
telegram_requested = bool(args.telegram_bot_token) or raw_chat_id.lower() not in {"", "auto"}
Expand Down Expand Up @@ -230,7 +234,7 @@ def reply_to_source(source: str, message: str) -> None:
print(message, file=sys.stderr)

btw_agent = BtwAgent(
runner=CodexRunner(codex_bin=args.codex_bin),
runner=build_codex_runner(codex_bin=args.codex_bin, config=copilot_proxy),
config=BtwConfig(
working_dir=str(Path.cwd()),
model=args.plan_model or args.reviewer_model or args.main_model,
Expand Down Expand Up @@ -377,7 +381,11 @@ def on_control_command(command) -> None:
channel.start(on_control_command)

event_sink = CompositeEventSink(sinks)
runner = CodexRunner(codex_bin=args.codex_bin, event_callback=event_sink.handle_stream_line)
runner = build_codex_runner(
codex_bin=args.codex_bin,
config=copilot_proxy,
event_callback=event_sink.handle_stream_line,
)
reviewer = Reviewer(runner=runner)
planner = Planner(runner=runner) if args.plan_mode != "off" else None
engine = LoopEngine(
Expand Down
15 changes: 13 additions & 2 deletions codex_autoloop/apps/daemon_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from ..adapters.control_channels import LocalBusControlChannel, TelegramControlChannel
from ..btw_agent import BtwAgent, BtwConfig
from ..codex_runner import CodexRunner
from ..copilot_proxy import build_codex_runner, config_from_args, format_proxy_summary
from ..daemon_bus import BusCommand, JsonlCommandBus, read_status, write_status
from ..model_catalog import get_preset
from ..telegram_notifier import TelegramConfig, TelegramNotifier, resolve_chat_id
Expand Down Expand Up @@ -48,6 +48,9 @@ def __init__(self, args: argparse.Namespace) -> None:
self.status_path = self.bus_dir / "daemon_status.json"
self.next_run_new_session_flag_path = self.bus_dir / "next_run_new_session.flag"
preset = get_preset(args.run_model_preset) if getattr(args, "run_model_preset", None) else None
run_copilot_proxy = config_from_args(args, prefix="run_")
if run_copilot_proxy.enabled:
print(f"[daemon] Copilot proxy mode: {format_proxy_summary(run_copilot_proxy)}", file=sys.stderr)

self.token_lock: TokenLock | None = None
self.notifier: TelegramNotifier | None = None
Expand All @@ -62,7 +65,7 @@ def __init__(self, args: argparse.Namespace) -> None:
self.child_started_at: dt.datetime | None = None
self.child_control_bus: JsonlCommandBus | None = None
self.btw_agent = BtwAgent(
runner=CodexRunner(),
runner=build_codex_runner(codex_bin="codex", config=run_copilot_proxy),
config=BtwConfig(
working_dir=str(self.run_cwd),
model=(
Expand Down Expand Up @@ -600,6 +603,14 @@ def build_child_command(
cmd.append("--no-telegram-live-updates")
if args.telegram_control_whisper_api_key:
cmd.extend(["--telegram-control-whisper-api-key", args.telegram_control_whisper_api_key])
if getattr(args, "run_copilot_proxy", False):
cmd.append("--copilot-proxy")
else:
cmd.append("--no-copilot-proxy")
run_copilot_proxy_dir = str(getattr(args, "run_copilot_proxy_dir", "") or "").strip()
if run_copilot_proxy_dir:
cmd.extend(["--copilot-proxy-dir", run_copilot_proxy_dir])
cmd.extend(["--copilot-proxy-port", str(int(getattr(args, "run_copilot_proxy_port", 18080)))])
if resume_session_id:
cmd.extend(["--session-id", resume_session_id])
if args.run_skip_git_repo_check:
Expand Down
18 changes: 18 additions & 0 deletions codex_autoloop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
from pathlib import Path

from .copilot_proxy import AUTO_DETECTED_PROXY_DIR_HELP
from .apps.cli_app import run_cli
from .apps.shell_utils import (
control_help_text,
Expand Down Expand Up @@ -79,6 +80,23 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("objective", nargs="+", help="Task objective passed to the primary agent.")
parser.add_argument("--codex-bin", default="codex", help="Codex CLI binary path.")
parser.add_argument(
"--copilot-proxy",
action=argparse.BooleanOptionalAction,
default=False,
help="Route Codex CLI requests through a local copilot-proxy instance.",
)
parser.add_argument(
"--copilot-proxy-dir",
default=None,
help=f"Path to the local copilot-proxy checkout. {AUTO_DETECTED_PROXY_DIR_HELP}",
)
parser.add_argument(
"--copilot-proxy-port",
type=int,
default=18080,
help="Local copilot-proxy port.",
)
parser.add_argument("--session-id", default=None, help="Resume an existing Codex exec session id.")
parser.add_argument("--max-rounds", type=int, default=500, help="Maximum primary-agent rounds.")
parser.add_argument(
Expand Down
17 changes: 15 additions & 2 deletions codex_autoloop/codex_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,17 @@ class RunnerOptions:


class CodexRunner:
def __init__(self, codex_bin: str = "codex", event_callback: EventCallback | None = None) -> None:
def __init__(
self,
codex_bin: str = "codex",
event_callback: EventCallback | None = None,
default_extra_args: list[str] | None = None,
before_exec: Callable[[], None] | None = None,
) -> None:
self.codex_bin = codex_bin
self.event_callback = event_callback
self.default_extra_args = list(default_extra_args or [])
self.before_exec = before_exec

def run_exec(
self,
Expand All @@ -61,6 +69,8 @@ def run_exec(
options: RunnerOptions,
run_label: str | None = None,
) -> CodexRunResult:
if self.before_exec is not None:
self.before_exec()
command = self._build_command(prompt=prompt, resume_thread_id=resume_thread_id, options=options)
command[0] = self._resolve_executable(command[0])
process = subprocess.Popen(
Expand Down Expand Up @@ -275,8 +285,11 @@ def _build_command(self, *, prompt: str, resume_thread_id: str | None, options:
command.append("--skip-git-repo-check")
if options.output_schema_path and not resume_thread_id:
command.extend(["--output-schema", options.output_schema_path])
merged_extra_args = [*self.default_extra_args]
if options.extra_args:
command.extend(options.extra_args)
merged_extra_args.extend(options.extra_args)
if merged_extra_args:
command.extend(merged_extra_args)
if resume_thread_id:
command.append(resume_thread_id)
# Always stream the prompt through stdin so multiline prompts survive
Expand Down
46 changes: 46 additions & 0 deletions codex_autoloop/codexloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Any

from .apps.daemon_app import render_plan_context, render_review_context
from .copilot_proxy import bootstrap_proxy_checkout, managed_proxy_dir, resolve_proxy_dir
from .daemon_bus import BusCommand, JsonlCommandBus, read_status
from .model_catalog import MODEL_PRESETS

Expand Down Expand Up @@ -336,6 +337,9 @@ def run_interactive_config(*, home_dir: Path, run_cd: Path) -> dict[str, Any]:
feishu_chat_id = prompt_input("Feishu chat id: ", default="").strip() or None
check_cmd = prompt_input("Default check command (optional): ", default="").strip()
model_preset = prompt_model_choice()
use_copilot_proxy, copilot_proxy_dir, copilot_proxy_port = prompt_copilot_proxy_choice(
preferred=(model_preset == "copilot")
)
play_mode = prompt_play_mode()
print(f"Run working directory: {run_cd}")
return {
Expand All @@ -362,6 +366,9 @@ def run_interactive_config(*, home_dir: Path, run_cd: Path) -> dict[str, Any]:
"run_main_model": None,
"run_reviewer_model": None,
"run_model_preset": model_preset,
"run_copilot_proxy": use_copilot_proxy,
"run_copilot_proxy_dir": copilot_proxy_dir,
"run_copilot_proxy_port": copilot_proxy_port,
"bus_dir": str((home_dir / "bus").resolve()),
"logs_dir": str((home_dir / "logs").resolve()),
}
Expand Down Expand Up @@ -457,6 +464,36 @@ def prompt_model_choice() -> str | None:
print("Selection out of range. Please choose one of the listed numbers.", file=sys.stderr)


def prompt_copilot_proxy_choice(*, preferred: bool = False) -> tuple[bool, str | None, int]:
detected = resolve_proxy_dir()
if detected is not None:
use_proxy = prompt_yes_no(
f"Detected copilot-proxy at {detected}. Use it for Codex runs?",
default=preferred,
)
if not use_proxy:
return False, None, 18080
return True, str(detected), 18080
if not preferred:
return False, None, 18080
target_dir = managed_proxy_dir()
install_proxy = prompt_yes_no(
f"No local copilot-proxy checkout was found. Install it automatically into {target_dir}?",
default=True,
)
if not install_proxy:
return False, None, 18080
try:
resolved = bootstrap_proxy_checkout(
on_progress=lambda message: print(f"[copilot-proxy] {message}", file=sys.stderr),
)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return False, None, 18080
print(f"[copilot-proxy] Ready at {resolved}", file=sys.stderr)
return True, str(resolved), 18080


def prompt_play_mode() -> PlayMode:
print("Choose Planner Mode:")
for idx, mode in enumerate(PLAY_MODES, start=1):
Expand Down Expand Up @@ -720,6 +757,15 @@ def build_daemon_command(*, config: dict[str, Any], home_dir: Path, token_lock_d
run_model_preset = str(config.get("run_model_preset") or "").strip()
if run_model_preset:
cmd.extend(["--run-model-preset", run_model_preset])
if bool(config.get("run_copilot_proxy")):
cmd.append("--run-copilot-proxy")
else:
cmd.append("--no-run-copilot-proxy")
run_copilot_proxy_dir = str(config.get("run_copilot_proxy_dir") or "").strip()
if run_copilot_proxy_dir:
cmd.extend(["--run-copilot-proxy-dir", run_copilot_proxy_dir])
run_copilot_proxy_port = int(config.get("run_copilot_proxy_port") or 18080)
cmd.extend(["--run-copilot-proxy-port", str(run_copilot_proxy_port)])
run_plan_record_file = str(config.get("run_plan_record_file") or "").strip()
if run_plan_record_file:
cmd.extend(["--run-plan-record-file", run_plan_record_file])
Expand Down
Loading
Loading