Skip to content
Open
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
92 changes: 92 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What is ArgusBot

ArgusBot is a Python supervisor plugin that wraps Codex CLI and Claude Code CLI with an automatic loop. A main agent executes tasks, a reviewer sub-agent gates completion (`done`/`continue`/`blocked`), and a planner sub-agent maintains a live framework view. The loop only stops when the reviewer says `done` and all acceptance checks pass.

## Build & Run

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e . # editable install
pip install pytest # for running tests
```

## Testing

```bash
pytest -q # run all tests
pytest tests/test_loop_engine.py # run a single test file
pytest -k test_loop_engine_stops # run a specific test by name
```

Tests live in `tests/` and use lightweight stub classes (no external services needed). CI runs pytest across Python 3.10–3.13.

## CLI Entry Points (defined in pyproject.toml)

| Command | Module | Purpose |
|---|---|---|
| `argusbot` | `codex_autoloop.codexloop:main` | One-word entrypoint: first-run setup, later attach monitor |
| `argusbot-run` | `codex_autoloop.cli:main` | Direct run with full CLI flags |
| `argusbot-daemon` | `codex_autoloop.telegram_daemon:main` | Always-on daemon for Telegram/Feishu idle control |
| `argusbot-daemon-ctl` | `codex_autoloop.daemon_ctl:main` | Terminal control for a running daemon |
| `argusbot-setup` | `codex_autoloop.setup_wizard:main` | Interactive first-run wizard |
| `argusbot-models` | `codex_autoloop.model_catalog:main` | List model presets |

## Architecture (three layers)

```
codex_autoloop/
core/ # Pure loop runtime, no I/O integration
engine.py # LoopEngine: run main → checks → reviewer → planner → repeat
ports.py # Protocol contracts: EventSink, ControlChannel, NotificationSink
state_store.py # Mutable runtime state, operator messages, injections, stop requests

adapters/ # Turn external sources into core abstractions
control_channels.py # Telegram/Feishu/bus → ControlCommand
event_sinks.py # Core events → terminal/dashboard/Telegram/Feishu output

apps/ # Executable shells that wire layers together
cli_app.py # argusbot-run wiring
daemon_app.py # argusbot-daemon wiring
shell_utils.py # Shared shell-facing helpers
```

Top-level modules (`orchestrator.py`, `control_state.py`, `codexloop.py`, `cli.py`) are compatibility wrappers that delegate to the three-layer internals.

### Key domain types (`models.py`)

- `CodexRunResult` — output from a single runner invocation
- `ReviewDecision` — structured reviewer verdict (status/confidence/reason/next_action)
- `PlanDecision` — planner output (follow_up_required, instructions, overview markdown)
- `RoundSummary` — per-round aggregate of main result + checks + review + plan
- `PlanMode` — `Literal["off", "auto", "record"]`

### Runner backends (`runner_backend.py`, `codex_runner.py`)

Two backends: `codex` (Codex CLI) and `claude` (Claude Code CLI). `CodexRunner` handles subprocess management, JSONL event parsing, stall watchdog, and session resume for both. Claude maps `xhigh` effort to `high`.

### Loop lifecycle (`core/engine.py`)

Each round: run main agent → run `--check` commands → run reviewer → optionally run planner → decide stop or continue. Stop conditions: reviewer `done` + checks pass, reviewer `blocked`, max rounds, or repeated no-progress.

### Event flow

Structured events (`loop.started`, `round.started`, `round.main.completed`, `round.checks.completed`, `round.review.completed`, `plan.completed`, `loop.completed`) flow through `EventSink` to terminal, dashboard, Telegram, and Feishu adapters.

### Daemon architecture

The daemon (`apps/daemon_app.py`) polls for commands via `JsonlCommandBus` (`daemon_bus.py`) and Telegram/Feishu adapters, spawns child `argusbot-run` processes, and manages lifecycle. `token_lock.py` enforces one active daemon per Telegram token.

## Per-project runtime state

When ArgusBot operates on a target project, it creates `.argusbot/` in that project with:
- `daemon_config.json` — persisted setup config
- `bus/` — JSONL command bus for daemon↔terminal IPC
- `logs/` — operator messages, run archive JSONL, daemon events

## Copilot proxy

Optional for Codex backend only. Routes through a local `copilot-proxy` checkout for GitHub Copilot-backed quota. Auto-detected from `~/copilot-proxy`, `~/copilot-codex-proxy`, or `~/.argusbot/tools/copilot-proxy`.
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ During `argusbot init`, first choose control channel (`1. Telegram`, `2. Feishu
- Single-word operator entrypoint: `argusbot` (first run setup, later auto-attach monitor).
- Token-exclusive daemon lock: one active daemon per Telegram token.
- Operator message history persisted to markdown and fed to reviewer decisions.
- Final task report generated after reviewer `done`, with optional `--final-report-file` output path and notifier delivery when ready.
- PPTX auto-generation for run handoff: builds a presentation-ready slide deck summarizing the completed work.
- Interactive PPTX opt-in: when running `argusbot-run` interactively, the CLI asks whether to generate a PPTX report before starting. Answer `Y` (default) to enable or `n` to skip. Daemon-launched runs (Telegram/Feishu) also ask via the control channel before each `/run` launch — reply `Y` or `N` to confirm. Use `--pptx-report` / `--no-pptx-report` to bypass the prompt.
- Final handoff artifacts generated after reviewer `done`: Markdown via `--final-report-file` and PPTX via `--pptx-report-file`, with notifier delivery when ready.
- Run archive persisted as JSONL with date/workspace/session metadata for resume continuity.
- Utility scripts: start/kill/watch daemon logs, plus sanitized cross-project setup examples.

Expand All @@ -119,6 +121,16 @@ source .venv/bin/activate
pip install -e .
```

### PPTX Report Dependencies (optional)

The PPTX run report generator uses a Node.js script. To enable it:

```bash
npm install # installs pptxgenjs and other JS dependencies
```

If `node` is not available, PPTX generation is silently skipped and does not block the loop.

## GitHub Copilot via `copilot-proxy`

ArgusBot can route Codex backend calls through a local `copilot-proxy` checkout, so main/reviewer/planner/BTW runs can use GitHub Copilot-backed quota instead of OpenAI API billing.
Expand Down Expand Up @@ -223,6 +235,20 @@ argusbot-run \
"Implement feature X and keep iterating until tests pass"
```

Report artifact example:

```bash
argusbot-run \
--state-file .argusbot/state.json \
--final-report-file .argusbot/review_summaries/final-task-report.md \
--pptx-report-file .argusbot/run-report.pptx \
"实现功能并同时产出 Markdown 与 PPTX 汇报"
```

This keeps both handoff artifacts in predictable paths. The PPTX report is also the file pushed by Telegram/Feishu when the run emits `pptx.report.ready`.

Release note: if `--pptx-report-file` is not passed, ArgusBot still resolves a default PPTX artifact path under the run artifact directory using the standard file name `run-report.pptx`.

Common options:

- `--runner-backend {codex,claude}`: select the execution backend
Expand All @@ -236,6 +262,7 @@ Common options:
- `--full-auto`: pass full-auto mode to Codex
- `--state-file <file>`: write round-by-round state JSON
- `--final-report-file <file>`: write the final handoff Markdown report after reviewer `done`
- `--pptx-report-file <file>`: write the auto-generated PPTX run report (default artifact name: `run-report.pptx`)
- `--plan-report-file <file>`: write the latest planner markdown snapshot
- `--plan-todo-file <file>`: write the latest planner TODO board markdown
- `--plan-update-interval-seconds 1800`: run background planning sweeps every 30 minutes
Expand Down
14 changes: 14 additions & 0 deletions codex_autoloop/adapters/event_sinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ def handle_event(self, event: dict[str, object]) -> None:
if event_type == "final.report.ready":
self._stop_stream_reporter(flush=False)
_send_final_report_via_notifier(self.notifier, event)
elif event_type == "pptx.report.ready":
_send_pptx_report_via_notifier(self.notifier, event)
elif event_type == "loop.completed":
self._stop_stream_reporter(flush=False)
self.notifier.notify_event(event)
Expand Down Expand Up @@ -144,6 +146,8 @@ def handle_event(self, event: dict[str, object]) -> None:
if event_type == "final.report.ready":
self._stop_stream_reporter(flush=False)
_send_final_report_via_notifier(self.notifier, event)
elif event_type == "pptx.report.ready":
_send_pptx_report_via_notifier(self.notifier, event)
elif event_type == "loop.completed":
self._stop_stream_reporter(flush=False)
self.notifier.notify_event(event)
Expand Down Expand Up @@ -181,6 +185,16 @@ def _send_final_report_via_notifier(notifier, event: dict[str, object]) -> None:
notifier.send_local_file(path, caption="ArgusBot final task report")


def _send_pptx_report_via_notifier(notifier, event: dict[str, object]) -> None:
raw_path = str(event.get("path") or "").strip()
if not raw_path:
return
path = Path(raw_path)
if not path.exists():
return
notifier.send_local_file(path, caption="ArgusBot run report (PPTX)")


def _render_final_report_message(event: dict[str, object]) -> str:
raw_path = str(event.get("path") or "").strip()
if not raw_path:
Expand Down
13 changes: 13 additions & 0 deletions codex_autoloop/apps/cli_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
resolve_btw_messages_file,
resolve_final_report_file,
resolve_plan_overview_file,
resolve_pptx_report_file,
resolve_review_summaries_dir,
resolve_operator_messages_file,
)
Expand Down Expand Up @@ -72,6 +73,15 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]:
control_file=args.control_file,
state_file=args.state_file,
)
if getattr(args, "pptx_report", None) is False:
pptx_report_file: str | None = None
else:
pptx_report_file = resolve_pptx_report_file(
explicit_path=getattr(args, "pptx_report_file", None),
operator_messages_file=operator_messages_file,
control_file=args.control_file,
state_file=args.state_file,
)
btw_messages_file = resolve_btw_messages_file(
explicit_path=None,
operator_messages_file=operator_messages_file,
Expand All @@ -85,6 +95,7 @@ def run_cli(args: Namespace) -> tuple[dict[str, Any], int]:
plan_overview_file=plan_overview_file,
review_summaries_dir=review_summaries_dir,
final_report_file=final_report_file,
pptx_report_file=pptx_report_file,
main_prompt_file=args.main_prompt_file,
check_commands=args.check or [],
plan_mode=args.plan_mode,
Expand Down Expand Up @@ -482,6 +493,8 @@ def on_control_command(command) -> None:
"review_summaries_dir": state_store.review_summaries_dir(),
"final_report_file": state_store.final_report_path(),
"final_report_ready": state_store.has_final_report(),
"pptx_report_file": state_store.pptx_report_path(),
"pptx_report_ready": state_store.has_pptx_report(),
"rounds": [
{
"round": item.round_index,
Expand Down
31 changes: 29 additions & 2 deletions codex_autoloop/apps/daemon_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ def __init__(self, args: argparse.Namespace) -> None:
self.child_started_at: dt.datetime | None = None
self.child_control_bus: JsonlCommandBus | None = None
self.pending_attachment_batches: dict[str, list[Any]] = {}
self.pending_pptx_run_objective: str | None = None
self.pending_pptx_run_source: str | None = None
self.btw_agent = BtwAgent(
runner=build_codex_runner(
backend=getattr(args, "run_runner_backend", DEFAULT_RUNNER_BACKEND),
Expand Down Expand Up @@ -191,6 +193,22 @@ def _build_control_channels(self) -> list[object]:

def _on_command(self, command) -> None:
self._log_event("command.received", source=command.source, kind=command.kind, text=command.text[:700])

# Handle pending PPTX confirmation reply
if self.pending_pptx_run_objective is not None:
reply = command.text.strip().lower()
if reply in ("y", "yes", "n", "no"):
objective = self.pending_pptx_run_objective
pptx_enabled = reply in ("y", "yes")
self.pending_pptx_run_objective = None
self.pending_pptx_run_source = None
self._start_child(objective, pptx_report=pptx_enabled)
return
self._send_reply(command.source, "[daemon] PPTX confirmation cancelled. Send /run again to start.")
self.pending_pptx_run_objective = None
self.pending_pptx_run_source = None
# fall through to handle the command normally

if command.kind == "help":
self._send_reply(command.source, help_text())
return
Expand Down Expand Up @@ -327,7 +345,10 @@ def _on_command(self, command) -> None:
else:
self._send_reply(command.source, "[daemon] active run exists but child control bus unavailable.")
return
self._start_child(objective)
# Ask about PPTX report before launching
self.pending_pptx_run_objective = objective
self.pending_pptx_run_source = command.source
self._send_reply(command.source, "Generate a PPTX run report at the end? Reply Y or N")
return
if command.kind in {"plan", "review"}:
if not self._child_running():
Expand Down Expand Up @@ -386,7 +407,7 @@ def on_complete(result) -> None:
self._send_reply(command.source, "[daemon] stopping daemon.")
self._stopping = True

def _start_child(self, objective: str) -> None:
def _start_child(self, objective: str, *, pptx_report: bool = True) -> None:
assert self.notifier is not None
timestamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
log_path = self.logs_dir / f"run-{timestamp}.log"
Expand All @@ -413,6 +434,7 @@ def _start_child(self, objective: str) -> None:
review_summaries_dir=str(review_summaries_dir),
resume_session_id=resume_session_id,
force_new_session=force_new_session,
pptx_report=pptx_report,
)
log_file = log_path.open("w", encoding="utf-8")
self.child = subprocess.Popen(cmd, stdout=log_file, stderr=log_file, text=True, cwd=self.run_cwd)
Expand Down Expand Up @@ -566,6 +588,7 @@ def build_child_command(
review_summaries_dir: str,
resume_session_id: str | None,
force_new_session: bool = False,
pptx_report: bool = True,
) -> list[str]:
preset = get_preset(args.run_model_preset) if args.run_model_preset else None
main_model = preset.main_model if preset is not None else args.run_main_model
Expand Down Expand Up @@ -665,6 +688,10 @@ def build_child_command(
cmd.extend(["--state-file", args.run_state_file])
if args.run_no_dashboard:
cmd.append("--no-dashboard")
if pptx_report:
cmd.append("--pptx-report")
else:
cmd.append("--no-pptx-report")
cmd.append(objective)
return cmd

Expand Down
25 changes: 25 additions & 0 deletions codex_autoloop/apps/shell_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ def resolve_final_report_file(
)


def resolve_pptx_report_file(
*,
explicit_path: str | None,
operator_messages_file: str | None,
control_file: str | None,
state_file: str | None,
default_root: str | None = None,
) -> str:
if explicit_path:
return explicit_path
base = _resolve_artifact_dir(
operator_messages_file=operator_messages_file,
control_file=control_file,
state_file=state_file,
default_root=default_root,
)
return str(base / "run-report.pptx")


def format_control_status(state: dict[str, Any]) -> str:
status = state.get("status", "unknown")
round_index = state.get("round", 0)
Expand All @@ -125,6 +144,8 @@ def format_control_status(state: dict[str, Any]) -> str:
review_summaries_dir = state.get("review_summaries_dir")
final_report_file = state.get("final_report_file")
final_report_ready = state.get("final_report_ready")
pptx_report_file = state.get("pptx_report_file")
pptx_report_ready = state.get("pptx_report_ready")
lines = [
"[autoloop] status",
f"status={status}",
Expand All @@ -149,6 +170,10 @@ def format_control_status(state: dict[str, Any]) -> str:
lines.append(f"final_report_file={final_report_file}")
if final_report_ready is not None:
lines.append(f"final_report_ready={final_report_ready}")
if pptx_report_file:
lines.append(f"pptx_report_file={pptx_report_file}")
if pptx_report_ready is not None:
lines.append(f"pptx_report_ready={pptx_report_ready}")
return "\n".join(lines)


Expand Down
Loading