diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..4cf67e8 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,33 @@ +name: Deploy website + +on: + push: + branches: [main] + paths: + - 'site/**' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index efd656b..3ed379a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,11 @@ node_modules/ dist/ coverage/ +# Demo renderer cache +.venv-demo/ +__pycache__/ +*.pyc + # Loop Engineer runtime data (runs, worktrees, caches) — never version these .loop-engineer/ diff --git a/.prettierignore b/.prettierignore index 39336ca..3933a31 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ node_modules/ package-lock.json CHANGELOG.md .loop-engineer/ +.venv-demo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 01f0031..568342a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ # Changelog -All notable changes will appear in this file. The project follows Keep a Changelog and plans to use Semantic Versioning after the first public release. +All notable changes will appear in this file. The project follows Keep a Changelog and Semantic Versioning. ## [Unreleased] +## [0.1.0] - 2026-07-15 + +First public release. + ### Added - Local-first Claude Code, Codex CLI and predefined-command providers. diff --git a/README.md b/README.md index 61f01e9..38212f8 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,76 @@ +
+ # Loop Engineer -Assign Claude, Codex and local tools to different software-engineering roles and run a controlled development loop in an isolated Git worktree. +**Loop Engineer is an open-source, local-first multi-agent software-engineering orchestrator for Claude Code, Codex CLI, and isolated Git worktrees.** + +Run Claude Code, OpenAI Codex and local checks as one controlled software-development loop. + +[![CI](https://github.com/BotondCsereklye/LoopEngineer/actions/workflows/ci.yml/badge.svg)](https://github.com/BotondCsereklye/LoopEngineer/actions/workflows/ci.yml) +[![Node.js 20+](https://img.shields.io/badge/Node.js-20%2B-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![License: MIT](https://img.shields.io/badge/license-MIT-22c55e)](LICENSE) +[![Local first](https://img.shields.io/badge/runtime-local--first-111827)](docs/security.md) + +[Website](https://botondcsereklye.github.io/LoopEngineer/) · [Demo](#demo) · [Screenshots](#screenshots) · [Quick start](#quick-start) · [How it works](#how-it-works) · [Security](#security-boundaries) · [Documentation](#documentation) + +
-Use your existing authenticated coding-agent CLIs. No API keys, cloud account or automatic push required. +Loop Engineer gives each agent one role, validates every handoff and keeps writing agents inside an isolated Git worktree. Tests and review gates decide when the loop stops. You inspect the result before anything reaches your branch. > [!WARNING] -> Loop Engineer is an unofficial open-source project. It has no affiliation with OpenAI or Anthropic. Review agent output and the generated diff before you copy changes into your branch. +> Loop Engineer is an unofficial open-source project. OpenAI and Anthropic do not sponsor or endorse it. Review generated code and provider output before using either. + +## Demo + +Check both provider CLIs without exposing credentials: + +![Loop Engineer provider connection demo](demo/clips/01-provider-connections.gif) + +Preview the workflow, then run the controlled loop: + +![Loop Engineer dry run and controlled workflow demo](demo/clips/02-controlled-loop.gif) + +[Watch the full 23-second MP4](demo/loop-engineer-demo.mp4) or browse the [demo notes and reproducible renderer](demo/README.md). + +The recordings use scripted fixture output. They never read a real `~/.claude`, `~/.codex`, repository file, environment secret or provider session. + +## Screenshots + +### Dashboard and provider connections + +See the local project, provider connection states and current-run panel before starting a task. + +![Loop Engineer dashboard and provider connections](demo/screenshots/01-dashboard-overview.jpg) + +### Model and intelligence controls -## Problem +Choose Claude Code or OpenAI Codex, then select the model and intelligence level for each role. -Coding agents can edit quickly, but a long unstructured chat mixes planning, implementation and approval. It can also expose a repository to prompt injection or let a test command exceed the authority you intended to grant. +![Loop Engineer model and intelligence controls](demo/screenshots/02-model-intelligence.jpg) -Loop Engineer assigns one role per step. Each role receives a bounded prompt, a permission profile and a Zod-validated JSON handoff. Writing roles work in a detached Git worktree. The tester runs commands from an exact allowlist. +### Quality gates and launch controls -## Workflow +Set blocking severities, require passing tests and start with a safe dry run. + +![Loop Engineer quality gates and launch controls](demo/screenshots/03-quality-gates.jpg) + +These screenshots come from the running local dashboard against a disposable Git fixture. They contain no repository source or credentials. + +## Why Loop Engineer + +A long agent chat mixes discovery, implementation, testing and approval in one context. Loop Engineer splits those responsibilities and gives each phase a narrow contract. + +| Concern | Loop Engineer behavior | +| ------------ | ----------------------------------------------------------------------------------------------- | +| Agent access | Read-only roles inspect the repository. Writing roles edit only an isolated worktree. | +| Handoffs | Zod validates structured JSON between roles. Raw chat transcripts do not become workflow state. | +| Commands | The local tester runs only configured allowlisted commands without a shell. | +| Quality | Tests and review findings must satisfy explicit gates. | +| Delivery | Every run ends with a Markdown report, JSON report and reviewable worktree. | +| Git | Loop Engineer creates no commit and sends no push. | + +## How it works ```text ANALYZE -> PLAN -> IMPLEMENT -> TEST -> REVIEW -> DECIDE @@ -22,23 +79,33 @@ ANALYZE -> PLAN -> IMPLEMENT -> TEST -> REVIEW -> DECIDE +------ FIX ``` -The orchestrator stops when tests and review gates pass, the cycle limit expires, runtime expires, progress stops, a provider fails, or the user cancels. +| Role | Default provider | Access | +| ----------- | -------------------- | ------------------- | +| Analyst | Claude Code or Codex | Read-only | +| Planner | Claude Code or Codex | Read-only | +| Implementer | Claude Code or Codex | Worktree write | +| Tester | Local command runner | Predefined commands | +| Reviewer | Claude Code or Codex | Read-only | +| Fixer | Claude Code or Codex | Worktree write | +| Final judge | Claude Code or Codex | Read-only | + +The orchestrator stops when the quality gates pass, a configured cycle or runtime limit expires, progress stalls, a provider fails, or you cancel the run. + +## Quick start -## Installation +### Install -Requirements: Node.js 20+, Git, and at least one supported official agent CLI. +Requirements: Node.js 20+, Git and at least one supported official provider CLI. ```bash -git clone -cd loop-engineer -npm install +git clone https://github.com/BotondCsereklye/LoopEngineer.git +cd LoopEngineer +npm ci npm run build npm link ``` -Loop Engineer uses the sessions managed by `claude` and `codex`. Sign in through those CLIs. Do not paste provider passwords or browser tokens into Loop Engineer. - -## 30-second quickstart +### Configure a project Run these commands inside a Git repository with at least one commit: @@ -48,33 +115,29 @@ loopeng doctor loopeng gui ``` -The dashboard opens at `http://127.0.0.1:4317`. Configure the task, role providers, models, quality gates and test commands, then start with a dry run. Loop Engineer does not commit or push. +The dashboard opens at `http://127.0.0.1:4317`. Connect the installed provider CLIs, then choose a provider, model and intelligence level for every role. Codex model choices include Sol, Terra and Luna; Claude uses its supported CLI aliases. During a real run, **Current run** shows the active role, provider, model, intelligence level and elapsed thinking time. Session-limit errors include the affected provider, role and reset time when the CLI supplies one. -Prefer the terminal? Run `loopeng run --task "Add input validation to the settings parser"` instead. - -## Commands +Prefer the terminal: -```text -loopeng init -loopeng doctor -loopeng gui -loopeng gui --no-open --port 4318 -loopeng run --task "Add password reset" -loopeng run --task-file task.md -loopeng run --config loop-engineer.yml --task "Fix the parser" -loopeng run --dry-run --task "Preview this workflow" -loopeng status -loopeng report -loopeng clean [--force] +```bash +loopeng run --dry-run --task "Add input validation to the settings parser" +loopeng run --task "Add input validation to the settings parser" ``` -`gui` starts a local-only dashboard bound to `127.0.0.1`. It reads the same `loop-engineer.yml` as the CLI and keeps the role permission boundaries fixed. Stop it with `Ctrl+C`. +## Provider connections + +The dashboard can start `claude auth login --claudeai` or `codex login`. Each official CLI owns its browser flow, callback and credential store. Loop Engineer receives only installed and authenticated status. -`doctor` checks Node, Git, repository state, worktree support, provider installation, command detection, instruction files and write access. It reports an unknown authentication state when an official CLI offers no dependable probe. +The dashboard contains no password, API-key, OAuth-code or access-token field. Configure API-key, SSO, device-code and enterprise automation flows through the provider's official CLI. + +Read [provider setup and smoke tests](docs/providers.md). ## Configuration -`loopeng init` writes `loop-engineer.yml` and detects common build commands. Zod rejects unknown keys, invalid role permissions and unsafe tester assignments. +`loopeng init` writes `loop-engineer.yml` and detects common project commands. The schema rejects unknown keys, invalid permissions and unsafe tester assignments. + +
+Example configuration ```yaml version: 1 @@ -113,44 +176,78 @@ security: redact_secrets: true ``` -See [configuration](docs/configuration.md) for validation rules. +
-## Roles and providers +See the [configuration reference](docs/configuration.md) for every field and validation rule. -The MVP includes `analyst`, `planner`, `implementer`, `reviewer`, `tester`, `fixer` and `final_judge`. Claude Code and Codex CLI handle agent roles. The local provider runs test commands without a shell. +## Commands -Provider flags can change between CLI releases. Run `loopeng doctor` after you upgrade a provider. See [providers](docs/providers.md). +```text +loopeng init +loopeng doctor +loopeng gui [--no-open] [--port ] +loopeng run --task "Add password reset" +loopeng run --task-file task.md +loopeng run --config loop-engineer.yml --task "Fix the parser" +loopeng run --dry-run --task "Preview this workflow" +loopeng status +loopeng report +loopeng clean [--force] +``` -## Security model +`doctor` checks Node.js, Git, worktree support, repository state, provider installation and authentication, command detection, instruction files and write access. -- Repository text enters prompts inside untrusted-data fences. -- Read-only roles receive read-only provider permissions. -- Implementer and fixer receive workspace write access inside the isolated worktree. -- The tester rejects shell chaining, pipes, redirection, command substitution and denied binaries. -- Logs and reports redact common token, key and password patterns before storage. -- Loop Engineer issues no commit, push, force reset or destructive clean command. +## Security boundaries -Redaction catches common patterns, not every secret format. Run reports can contain sensitive source context. Keep `.loop-engineer/` local and review [the security model](docs/security.md). +- Repository content enters prompts inside untrusted-data fences. +- Analyst, planner, reviewer and final judge receive read-only provider permissions. +- Implementer and fixer write only inside the managed worktree. +- The tester rejects chaining, pipes, redirects, command substitution, denied binaries and destructive Git commands. +- Provider login stays inside the official CLI. The dashboard API never handles credentials. +- Logs and reports redact common key, token and password formats before storage. +- Loop Engineer never commits, pushes, force-resets or runs a destructive clean command. + +Redaction cannot recognize every custom secret format. Keep `.loop-engineer/` private and treat run reports like build logs. Read the full [security model](docs/security.md) before using the tool on sensitive code. ## Worktrees and reports -Loop Engineer creates `.loop-engineer/worktrees/` from the current commit. Existing modifications in your main checkout stay untouched. `clean` removes worktrees with Loop Engineer marker files and refuses dirty worktrees unless you pass `--force`. +Each real run starts from the current commit and creates: + +```text +.loop-engineer/ +├── runs// +│ ├── report.md +│ ├── report.json +│ ├── task.md +│ ├── config.snapshot.yml +│ └── validated handoffs and provider events +└── worktrees// + └── generated changes for human review +``` -Each completed run writes Markdown and JSON under `.loop-engineer/runs//`, together with configuration, task, handoffs, provider events, tests and review results. +`loopeng clean` removes only marked managed worktrees. It preserves dirty worktrees unless you pass `--force`. -## Limitations +## Documentation -- The MVP supports Claude Code, Codex CLI and a local command runner. -- Provider CLI output formats may change. -- The context firewall and redactor reduce risk but cannot prove that a provider will behave safely. -- Loop Engineer leaves the worktree for human inspection and does not apply its diff to your branch. -- Windows support depends on Git worktree behavior and provider CLI support on the host. +| Guide | Covers | +| -------------------------------------- | -------------------------------------------- | +| [Architecture](docs/architecture.md) | Components, trust boundaries and data flow | +| [Workflow](docs/workflow.md) | State machine, loops and stop conditions | +| [GUI](docs/gui.md) | Local dashboard and provider connections | +| [Providers](docs/providers.md) | Claude Code, Codex and the local runner | +| [Configuration](docs/configuration.md) | Schema, commands and quality gates | +| [Security](docs/security.md) | Process, prompt, credential and Git controls | +| [Development](docs/development.md) | Build, test and contribution workflow | +| [Roadmap](docs/roadmap.md) | Planned scope and exclusions | -## Roadmap +## Limitations -Planned work includes Gemini support, optional MCP integration, richer progress evidence and opt-in packaging as a single executable. Cloud accounts, browser automation, automatic pull requests and automatic pushes remain outside the MVP. See [roadmap](docs/roadmap.md). +- Provider command flags and machine-readable output can change between CLI releases. +- The context firewall and redactor reduce risk; they cannot prove provider behavior. +- Loop Engineer leaves generated changes in the worktree for manual inspection. +- Windows support depends on Git worktree behavior and provider CLI support on the host. -## Development and contributing +## Development ```bash npm ci @@ -161,11 +258,7 @@ npm run test:coverage npm run build ``` -Read [CONTRIBUTING.md](CONTRIBUTING.md) and [development notes](docs/development.md). Suggested GitHub topics: `ai-agents`, `claude-code`, `codex-cli`, `developer-tools`, `git-worktree`, `local-first`, `typescript`. - -## Disclaimer - -You control the provider sessions and repository. Check provider terms, usage limits, generated code, licenses and security impact before adopting a change. Loop Engineer does not bypass provider authentication or usage limits. +Read [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md) and the [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. ## License diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..862533a --- /dev/null +++ b/demo/README.md @@ -0,0 +1,36 @@ +# Demo + +These clips show the provider check, dry run and controlled development loop without touching a real Claude Code or Codex session. + +## Provider connections + +Loop Engineer checks the installed official CLIs and reports only installed and authenticated state. Credentials remain inside each provider's own credential store. + +![Provider connection check](clips/01-provider-connections.gif) + +## Controlled loop + +A dry run validates the workflow before any provider runs. A real run performs analysis, planning, implementation, tests and review in an isolated Git worktree. + +![Dry run and controlled development loop](clips/02-controlled-loop.gif) + +## Full video + +[Watch the full MP4 demo](loop-engineer-demo.mp4). + +## Rebuild the media + +The renderer uses deterministic fixture text. It does not read `~/.claude`, `~/.codex`, repository source files, environment secrets or provider output. + +Requirements: + +- Python 3 with Pillow +- FFmpeg with H.264 and GIF encoders + +```bash +python3 -m venv .venv-demo +.venv-demo/bin/pip install -r demo/requirements.txt +.venv-demo/bin/python demo/render_demo.py +``` + +The script writes the MP4, poster and GIF clips under `demo/`. diff --git a/demo/clips/01-provider-connections.gif b/demo/clips/01-provider-connections.gif new file mode 100644 index 0000000..ba3990d Binary files /dev/null and b/demo/clips/01-provider-connections.gif differ diff --git a/demo/clips/02-controlled-loop.gif b/demo/clips/02-controlled-loop.gif new file mode 100644 index 0000000..72f86dc Binary files /dev/null and b/demo/clips/02-controlled-loop.gif differ diff --git a/demo/loop-engineer-demo-poster.png b/demo/loop-engineer-demo-poster.png new file mode 100644 index 0000000..836aec4 Binary files /dev/null and b/demo/loop-engineer-demo-poster.png differ diff --git a/demo/loop-engineer-demo.mp4 b/demo/loop-engineer-demo.mp4 new file mode 100644 index 0000000..16aa1a4 Binary files /dev/null and b/demo/loop-engineer-demo.mp4 differ diff --git a/demo/render_demo.py b/demo/render_demo.py new file mode 100644 index 0000000..3806f7d --- /dev/null +++ b/demo/render_demo.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Render Loop Engineer demo media without touching provider sessions. + +The renderer draws deterministic fixture output with Pillow, streams raw frames +to FFmpeg, then derives compact GIF clips for the README. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +DEMO_DIR = ROOT / "demo" +CLIPS_DIR = DEMO_DIR / "clips" +VIDEO_PATH = DEMO_DIR / "loop-engineer-demo.mp4" +POSTER_PATH = DEMO_DIR / "loop-engineer-demo-poster.png" + +WIDTH = 1280 +HEIGHT = 720 +FPS = 24 +DURATION = 23.0 + +COLORS = { + "background": "#080b0f", + "surface": "#10151b", + "surface_2": "#151b22", + "line": "#26303a", + "text": "#f1f5f9", + "muted": "#94a3b8", + "green": "#22c55e", + "blue": "#60a5fa", + "orange": "#d97757", + "yellow": "#fbbf24", +} + +SCENES = [ + { + "start": 2.0, + "end": 7.2, + "label": "PROVIDER CHECK", + "command": "loopeng doctor", + "outputs": [ + ("✓ Node.js v22", "green"), + ("✓ Git worktrees available", "green"), + ("✓ Claude Code connected", "orange"), + ("✓ OpenAI Codex connected", "green"), + ("✓ Checks build · test · lint · typecheck", "blue"), + ], + "interval": 0.48, + }, + { + "start": 7.2, + "end": 13.4, + "label": "SAFE PREVIEW", + "command": 'loopeng run --dry-run --task "Add input validation"', + "outputs": [ + ("Configuration valid", "green"), + ("Roles: analyst → planner → implementer → tester → reviewer", "text"), + ("Quality gates: tests + clean review", "blue"), + ("Dry run complete · no providers called", "green"), + ("No files changed · no commit · no push", "muted"), + ], + "interval": 0.58, + }, + { + "start": 13.4, + "end": 21.2, + "label": "CONTROLLED LOOP", + "command": 'loopeng run --task "Add input validation"', + "outputs": [ + ("[1/7] Analyzing repository", "blue"), + ("[2/7] Creating implementation plan", "blue"), + ("[3/7] Isolated worktree created", "blue"), + ("[4/7] Implementing scoped changes", "text"), + ("[5/7] Tests passed", "green"), + ("[6/7] Review clean", "green"), + ("[7/7] Final validation", "blue"), + ("READY FOR HUMAN REVIEW", "green"), + ("Worktree: .loop-engineer/worktrees/run-demo", "muted"), + ], + "interval": 0.45, + }, +] + + +def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + candidates = [ + "/System/Library/Fonts/SFNSMono.ttf", + "/System/Library/Fonts/SFNSMonoItalic.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + ] + if bold: + candidates.insert(0, "/System/Library/Fonts/SFNSMono.ttf") + for candidate in candidates: + if Path(candidate).exists(): + return ImageFont.truetype(candidate, size=size) + return ImageFont.load_default() + + +FONT_SMALL = font(18) +FONT_BODY = font(27) +FONT_HEADER = font(20, bold=True) +FONT_TITLE = font(57, bold=True) +FONT_SUBTITLE = font(23) + + +def rounded_rectangle(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], radius: int, fill: str, outline: str | None = None, width: int = 1) -> None: + draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width) + + +def draw_shell(draw: ImageDraw.ImageDraw, label: str) -> None: + rounded_rectangle(draw, (52, 54, 1228, 666), 18, COLORS["surface"], COLORS["line"], 2) + draw.rounded_rectangle((52, 54, 1228, 112), radius=18, fill=COLORS["surface_2"]) + draw.rectangle((52, 92, 1228, 112), fill=COLORS["surface_2"]) + for x, color in [(84, "#fb7185"), (112, "#fbbf24"), (140, "#22c55e")]: + draw.ellipse((x - 7, 76 - 7, x + 7, 76 + 7), fill=color) + draw.text((174, 67), "loop-engineer · safe fixture demo", font=FONT_SMALL, fill=COLORS["muted"]) + label_width = draw.textlength(label, font=FONT_SMALL) + draw.text((1192 - label_width, 67), label, font=FONT_SMALL, fill=COLORS["green"]) + + +def draw_intro(draw: ImageDraw.ImageDraw, t: float) -> None: + progress = min(1.0, max(0.0, t / 0.7)) + title = "LOOP ENGINEER" + title_width = draw.textlength(title, font=FONT_TITLE) + x = (WIDTH - title_width) / 2 + draw.text((x, 260), title, font=FONT_TITLE, fill=COLORS["text"]) + line_width = int(390 * progress) + draw.rounded_rectangle((WIDTH // 2 - line_width // 2, 337, WIDTH // 2 + line_width // 2, 343), radius=3, fill=COLORS["green"]) + subtitle = "Claude Code + OpenAI Codex · one controlled development loop" + subtitle_width = draw.textlength(subtitle, font=FONT_SUBTITLE) + draw.text(((WIDTH - subtitle_width) / 2, 372), subtitle, font=FONT_SUBTITLE, fill=COLORS["muted"]) + note = "local-first · isolated worktrees · human review" + note_width = draw.textlength(note, font=FONT_SMALL) + draw.text(((WIDTH - note_width) / 2, 430), note, font=FONT_SMALL, fill=COLORS["blue"]) + + +def draw_scene(draw: ImageDraw.ImageDraw, scene: dict[str, object], t: float) -> None: + draw_shell(draw, str(scene["label"])) + local_t = t - float(scene["start"]) + command = str(scene["command"]) + typed_count = max(0, min(len(command), int((local_t - 0.25) * 32))) + typed = command[:typed_count] + cursor = "▌" if int(local_t * 3) % 2 == 0 and typed_count < len(command) else "" + draw.text((92, 145), "$", font=FONT_BODY, fill=COLORS["green"]) + draw.text((126, 145), typed + cursor, font=FONT_BODY, fill=COLORS["text"]) + + output_start = 0.25 + len(command) / 32 + 0.35 + interval = float(scene["interval"]) + outputs = list(scene["outputs"]) + for index, (line, color_key) in enumerate(outputs): + if local_t >= output_start + index * interval: + y = 205 + index * 41 + draw.text((92, y), str(line), font=FONT_BODY, fill=COLORS[str(color_key)]) + + footer = "Credentials and real provider sessions are never used in this recording." + draw.text((92, 620), footer, font=FONT_SMALL, fill=COLORS["muted"]) + + +def draw_outro(draw: ImageDraw.ImageDraw, t: float) -> None: + draw_shell(draw, "HUMAN HANDOFF") + heading = "Inspect the diff. You decide what ships." + draw.text((92, 210), heading, font=FONT_TITLE, fill=COLORS["text"]) + draw.text((94, 310), "Reports: Markdown + JSON", font=FONT_BODY, fill=COLORS["blue"]) + draw.text((94, 358), "Worktree: isolated and reviewable", font=FONT_BODY, fill=COLORS["green"]) + draw.text((94, 406), "Commit: never automatic", font=FONT_BODY, fill=COLORS["muted"]) + if int(t * 2) % 2 == 0: + draw.rectangle((94, 477, 110, 508), fill=COLORS["green"]) + + +def frame_at(t: float) -> Image.Image: + image = Image.new("RGB", (WIDTH, HEIGHT), COLORS["background"]) + draw = ImageDraw.Draw(image) + if t < 2.0: + draw_intro(draw, t) + return image + for scene in SCENES: + if float(scene["start"]) <= t < float(scene["end"]): + draw_scene(draw, scene, t) + return image + draw_outro(draw, t) + return image + + +def run(command: list[str]) -> None: + result = subprocess.run(command, cwd=ROOT, text=True, capture_output=True) + if result.returncode != 0: + print(result.stdout, file=sys.stderr) + print(result.stderr, file=sys.stderr) + raise SystemExit(result.returncode) + + +def render_video() -> None: + if shutil.which("ffmpeg") is None: + raise SystemExit("FFmpeg is required to render demo media") + CLIPS_DIR.mkdir(parents=True, exist_ok=True) + command = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "-s", + f"{WIDTH}x{HEIGHT}", + "-r", + str(FPS), + "-i", + "-", + "-an", + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + str(VIDEO_PATH), + ] + process = subprocess.Popen(command, cwd=ROOT, stdin=subprocess.PIPE) + assert process.stdin is not None + try: + for frame_number in range(round(DURATION * FPS)): + image = frame_at(frame_number / FPS) + process.stdin.write(image.tobytes()) + finally: + process.stdin.close() + if process.wait() != 0: + raise SystemExit("FFmpeg failed while encoding the demo video") + frame_at(16.7).save(POSTER_PATH, optimize=True) + + +def render_gif(name: str, start: float, duration: float) -> None: + target = CLIPS_DIR / name + filter_graph = ( + "fps=12,scale=960:-2:flags=lanczos,split[s0][s1];" + "[s0]palettegen=max_colors=96:stats_mode=diff[p];" + "[s1][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" + ) + run( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-ss", + str(start), + "-t", + str(duration), + "-i", + str(VIDEO_PATH), + "-filter_complex", + filter_graph, + "-loop", + "0", + str(target), + ] + ) + + +def main() -> None: + render_video() + render_gif("01-provider-connections.gif", 1.8, 5.6) + render_gif("02-controlled-loop.gif", 7.3, 14.1) + print(f"Rendered {VIDEO_PATH.relative_to(ROOT)}") + for clip in sorted(CLIPS_DIR.glob("*.gif")): + print(f"Rendered {clip.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..e7cc6c1 --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1 @@ +Pillow==12.3.0 diff --git a/demo/screenshots/01-dashboard-overview.jpg b/demo/screenshots/01-dashboard-overview.jpg new file mode 100644 index 0000000..b546c9f Binary files /dev/null and b/demo/screenshots/01-dashboard-overview.jpg differ diff --git a/demo/screenshots/02-model-intelligence.jpg b/demo/screenshots/02-model-intelligence.jpg new file mode 100644 index 0000000..5c44521 Binary files /dev/null and b/demo/screenshots/02-model-intelligence.jpg differ diff --git a/demo/screenshots/03-quality-gates.jpg b/demo/screenshots/03-quality-gates.jpg new file mode 100644 index 0000000..f82a2c0 Binary files /dev/null and b/demo/screenshots/03-quality-gates.jpg differ diff --git a/docs/configuration.md b/docs/configuration.md index d1c2682..22aba3b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,8 @@ Role permissions have fixed safety constraints: The tester must use `local` in normal configuration. Tests may use the internal `fake` provider. +LLM role entries accept `model` and an optional `effort`. Supported effort values are `auto`, `low`, `medium`, `high`, `xhigh`, `max` and `ultra`; the chosen provider and model determine which values are valid in the GUI. `auto` leaves the provider default unchanged. Claude receives the value through `--effort`; Codex receives it as `model_reasoning_effort`. The local tester does not use a model or effort. + Quality gates can require passing commands, reviewer approval and zero findings at configured severities. The default blocks `critical` and `high`. The `install` command records a detected install command for future use. The MVP does not run it when `allow_package_install` is false, which is the default. diff --git a/docs/gui.md b/docs/gui.md index 06c31fb..c03626e 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -8,12 +8,22 @@ loopeng gui The browser opens `http://127.0.0.1:4317`. Use `--no-open` on a headless machine or `--port ` when the default port is occupied. -The dashboard lets you select Claude Code or Codex CLI and a model for each agent role, workflow limits, quality gates, blocking severities and the predefined local build, test, lint and typecheck commands. The tester remains local. Read-only and workspace-write permission profiles cannot be changed in the browser. +The dashboard lets you connect Claude Code or OpenAI Codex, then select a provider, provider-specific model and intelligence level for each agent role. Codex includes the locally supported Sol, Terra and Luna choices. Claude exposes its stable CLI aliases such as Opus, Sonnet and Haiku. Model availability can still vary by account and workspace policy. + +The **Current run** card shows both provider connection states, the active role, provider, model, intelligence level and elapsed thinking time. It displays safe progress metadata rather than private chain-of-thought. When a provider reports a session or usage limit, the card identifies the affected provider and role, shows the reset time when supplied by the CLI and suggests switching that role to the other provider. + +You can also configure workflow limits, quality gates, blocking severities and the predefined local build, test, lint and typecheck commands. The tester remains local. Read-only and workspace-write permission profiles cannot be changed in the browser. + +## Provider connections + +Select **Sign in with Claude** or **Sign in with OpenAI** to start the installed official CLI's browser login. Finish the flow in the browser opened by the CLI; the dashboard refreshes the connection state automatically. A green connection state confirms installation and authentication, not remaining subscription quota. Quota is verified only when the provider serves a real role request. + +Loop Engineer does not implement vendor OAuth, receive a callback, inspect credential files or store tokens. If browser login cannot complete, run `claude auth login --claudeai` or `codex login` directly in a terminal so the official CLI can present interactive recovery options. Dry run is enabled by default. A real run uses the same isolated worktree, command policy, context firewall, secret redaction and report store as `loopeng run`. ## Local security boundary -The HTTP server accepts only a loopback bind address. Mutating requests require a per-process CSRF token, the exact local origin and JSON content type. Responses use a restrictive Content Security Policy and do not enable cross-origin access. The UI never exposes controls for package installation, network tools, commits or pushes. +The HTTP server accepts only a loopback bind address. Mutating requests require a per-process CSRF token, the exact local origin and JSON content type. Provider IDs are allowlisted and login commands are fixed argument arrays executed without a shell. Responses use a restrictive Content Security Policy and do not enable cross-origin access. The UI never exposes controls for credentials, package installation, network tools, commits or pushes. Press `Ctrl+C` in the terminal that started the dashboard to stop the server. diff --git a/docs/providers.md b/docs/providers.md index 4308074..85aed88 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -2,11 +2,21 @@ ## Claude Code -The Claude adapter calls the official `claude` binary in print mode with JSON output. Read-only roles receive read tools. Writing roles receive edit tools and `acceptEdits`; the adapter does not grant Bash. Claude Code does not expose a stable non-interactive authentication probe across supported versions, so `doctor` may report the state as unknown. +The Claude adapter calls the official `claude` binary in print mode with JSON output. The selected model is passed through `--model` and the selected intelligence level through `--effort`. Read-only roles receive read tools. Writing roles receive edit tools and `acceptEdits`; the adapter does not grant Bash. Authentication status is checked with `claude auth status --json` using only its exit code. The dashboard starts subscription login with `claude auth login --claudeai`. ## Codex CLI -The Codex adapter calls `codex exec --json` and sends the prompt through stdin. It maps permissions to `read-only` or `workspace-write` sandbox modes. `doctor` uses the official `codex login status` command when available. +The Codex adapter calls `codex exec --json` and sends the prompt through stdin. The selected model is passed through `--model`; intelligence is passed through the `model_reasoning_effort` configuration override. It maps permissions to `read-only` or `workspace-write` sandbox modes. Authentication status is checked with `codex login status`; the dashboard starts browser login with `codex login`. + +The GUI catalog is provider-specific. Codex offers Sol, Terra and Luna plus compatible reasoning levels; Ultra is only offered for models whose local Codex catalog supports delegation. Claude offers Automatic, Best, Opus, Sonnet, Haiku and Opus-plan aliases with only compatible effort choices. The server validates every model and intelligence combination again before starting a run. + +An authenticated CLI can still have no remaining subscription quota. If a real request reports a session, usage or rate limit, the run fails closed and the dashboard shows the provider, role and vendor-provided reset time. Loop Engineer never estimates quota itself. + +## Dashboard sign-in + +The **Provider connections** panel delegates sign-in to the installed official CLI. The CLI opens and owns the vendor browser flow, callback and credential storage. Loop Engineer starts a fixed allowlisted command without a shell, discards its output and exposes only installed, connecting and authenticated status. + +The dashboard deliberately has no password, API-key, access-token, email or OAuth-code input. For API-key or enterprise automation authentication, configure the official CLI outside Loop Engineer and use its documented credential store. ## Local runner @@ -16,6 +26,7 @@ The local runner handles the tester role. It executes exact commands from `comma ```bash claude --version +claude auth status codex --version codex login status loopeng doctor diff --git a/docs/security.md b/docs/security.md index a82108a..f573c73 100644 --- a/docs/security.md +++ b/docs/security.md @@ -12,6 +12,12 @@ The process runner calls `spawn` with `shell: false`, caps captured output, supp Command parsing in the MVP splits on whitespace. Keep allowed commands simple. Put complex logic in a reviewed package script and allowlist `npm run `. +## Provider authentication + +The local dashboard delegates authentication to the installed official Claude Code and OpenAI Codex CLIs. It accepts only the fixed provider IDs `claude` and `codex`, starts fixed login argument arrays with `shell: false`, caps captured output and applies a timeout. CLI output is discarded and is never returned to the browser or written to the run store. + +The dashboard never accepts passwords, API keys, OAuth codes or access tokens. It does not read vendor credential files or implement an OAuth callback. The provider CLI owns browser authentication and credential storage. Users who need API-key, SSO, device-code or enterprise automation flows must configure those through the official CLI outside Loop Engineer. + ## Files and Git Writing providers receive the managed worktree as their current directory. Loop Engineer creates no commit and sends no push. Cleanup checks marker metadata, refuses paths outside `.loop-engineer/worktrees`, and preserves dirty worktrees unless the user supplies `--force`. diff --git a/eslint.config.js b/eslint.config.js index cc49bd0..1f83d92 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,7 +2,7 @@ import js from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist/**', 'coverage/**', 'node_modules/**'] }, + { ignores: ['dist/**', 'coverage/**', 'node_modules/**', '.venv-demo/**'] }, js.configs.recommended, ...tseslint.configs.recommended, { diff --git a/package.json b/package.json index a7cfb94..d1cb1d5 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,18 @@ { "name": "loop-engineer", "version": "0.1.0", - "description": "Local-first multi-agent orchestrator: assign Claude Code, Codex CLI and local tools to software-engineering roles and run a controlled development loop in an isolated Git worktree.", + "description": "Loop Engineer is an open-source, local-first multi-agent software-engineering orchestrator for Claude Code, Codex CLI, and isolated Git worktrees.", "type": "module", "license": "MIT", + "author": "Botond Csereklye", + "homepage": "https://botondcsereklye.github.io/LoopEngineer/", + "repository": { + "type": "git", + "url": "git+https://github.com/BotondCsereklye/LoopEngineer.git" + }, + "bugs": { + "url": "https://github.com/BotondCsereklye/LoopEngineer/issues" + }, "engines": { "node": ">=20" }, @@ -25,7 +34,10 @@ "cli", "git-worktree", "local-first", - "developer-tools" + "developer-tools", + "agent-orchestration", + "software-engineering", + "multi-agent" ], "scripts": { "build": "tsc -p tsconfig.build.json && node scripts/copy-gui-assets.mjs", diff --git a/site/assets/social-preview.png b/site/assets/social-preview.png new file mode 100644 index 0000000..6e7c331 Binary files /dev/null and b/site/assets/social-preview.png differ diff --git a/site/assets/style.css b/site/assets/style.css new file mode 100644 index 0000000..3b2cd86 --- /dev/null +++ b/site/assets/style.css @@ -0,0 +1,247 @@ +:root { + --bg: #ffffff; + --bg-soft: #f5f7fa; + --text: #1f2937; + --text-soft: #4b5563; + --heading: #0f172a; + --accent: #0369a1; + --accent-soft: #e0f2fe; + --border: #e2e8f0; + --code-bg: #0b1120; + --code-text: #e2e8f0; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b1120; + --bg-soft: #111a2e; + --text: #d1d5db; + --text-soft: #94a3b8; + --heading: #f1f5f9; + --accent: #38bdf8; + --accent-soft: #0c2a3f; + --border: #1e293b; + --code-bg: #020617; + --code-text: #e2e8f0; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.65; +} + +header.site { + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +header.site .inner { + max-width: 880px; + margin: 0 auto; + padding: 0.9rem 1.25rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1.1rem; +} + +header.site .brand { + font-weight: 700; + color: var(--heading); + text-decoration: none; + margin-right: auto; + font-size: 1.05rem; +} + +header.site .brand span { + color: var(--accent); +} + +header.site nav { + display: flex; + flex-wrap: wrap; + gap: 0.2rem 1rem; + font-size: 0.95rem; +} + +header.site nav a { + color: var(--text-soft); + text-decoration: none; +} + +header.site nav a:hover, +header.site nav a[aria-current='page'] { + color: var(--accent); +} + +main { + max-width: 880px; + margin: 0 auto; + padding: 2.5rem 1.25rem 4rem; +} + +h1, +h2, +h3 { + color: var(--heading); + line-height: 1.25; +} + +h1 { + font-size: 2.1rem; + margin: 0 0 0.75rem; +} + +h2 { + font-size: 1.45rem; + margin-top: 2.5rem; +} + +h3 { + font-size: 1.15rem; + margin-top: 1.8rem; +} + +a { + color: var(--accent); +} + +p.lead { + font-size: 1.15rem; + color: var(--text-soft); + max-width: 46rem; +} + +.hero { + padding: 1.5rem 0 0.5rem; +} + +.hero .actions { + margin-top: 1.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.button { + display: inline-block; + padding: 0.55rem 1.1rem; + border-radius: 8px; + text-decoration: none; + font-weight: 600; + border: 1px solid var(--accent); +} + +.button.primary { + background: var(--accent); + color: #fff; +} + +.button.secondary { + color: var(--accent); +} + +pre { + background: var(--code-bg); + color: var(--code-text); + padding: 1rem 1.25rem; + border-radius: 10px; + overflow-x: auto; + font-size: 0.9rem; + line-height: 1.55; +} + +code { + font-family: 'SF Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +p code, +li code, +td code { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.1rem 0.35rem; + font-size: 0.88em; +} + +.table-wrap { + overflow-x: auto; +} + +table { + border-collapse: collapse; + width: 100%; + font-size: 0.95rem; +} + +th, +td { + border: 1px solid var(--border); + padding: 0.55rem 0.75rem; + text-align: left; + vertical-align: top; +} + +th { + background: var(--bg-soft); + color: var(--heading); +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; + padding: 0; + list-style: none; +} + +.cards li { + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.1rem; + background: var(--bg-soft); +} + +.cards li strong { + display: block; + color: var(--heading); + margin-bottom: 0.3rem; +} + +.note { + border-left: 4px solid var(--accent); + background: var(--accent-soft); + padding: 0.75rem 1rem; + border-radius: 0 8px 8px 0; + margin: 1.5rem 0; +} + +footer.site { + border-top: 1px solid var(--border); + color: var(--text-soft); + font-size: 0.9rem; +} + +footer.site .inner { + max-width: 880px; + margin: 0 auto; + padding: 1.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + justify-content: space-between; +} + +footer.site a { + color: var(--text-soft); +} diff --git a/site/changelog.html b/site/changelog.html new file mode 100644 index 0000000..32adf66 --- /dev/null +++ b/site/changelog.html @@ -0,0 +1,93 @@ + + + + + + Loop Engineer Changelog – Releases and Notable Changes + + + + + + + + + + + +
+ +
+
+

Changelog

+

+ Loop Engineer follows Keep a Changelog and Semantic Versioning. The canonical changelog + lives in the repository: + CHANGELOG.md. +

+ +

v0.1.0 — July 2026

+

First public release.

+
    +
  • Local-first Claude Code, Codex CLI, and predefined-command providers.
  • +
  • Structured role handoffs, bounded correction loops, and objective quality gates.
  • +
  • Detached Git worktrees, redacted run reports, and safe cleanup.
  • +
  • + init, doctor, run, status, + report, and clean commands. +
  • +
  • + gui command: local-only dashboard (loopback bind, CSRF token, strict CSP) to + configure, start, watch, and cancel runs. +
  • +
  • + Provider-signaled subscription/session limits are classified as "provider unavailable" + instead of an internal error. +
  • +
+

+ See all releases on + GitHub Releases. +

+
+ + + diff --git a/site/comparison.html b/site/comparison.html new file mode 100644 index 0000000..c4d3abc --- /dev/null +++ b/site/comparison.html @@ -0,0 +1,155 @@ + + + + + + Loop Engineer vs. Claude Code Sessions and Codex CLI + + + + + + + + + + + +
+ +
+
+

Comparison

+

+ Loop Engineer does not replace Claude Code or Codex CLI. It orchestrates them — and adds the + structure a single agent session does not have. +

+ +

Loop Engineer vs. a normal Claude Code session

+

+ A long agent chat mixes discovery, implementation, testing, and approval in one context. + Loop Engineer splits those responsibilities and gives each phase a narrow contract. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConcernSingle agent sessionLoop Engineer
Agent accessOne agent with one permission level for everything + Read-only roles inspect the repository; writing roles edit only an isolated worktree +
State between stepsOne growing chat transcriptSchema-validated JSON handoffs; transcripts never become workflow state
Command executionAgent proposes shell commands as it goesLocal tester runs only configured allowlisted commands, without a shell
Definition of doneThe model decides it is finishedTests and review findings must satisfy explicit quality gates
ResultEdits land directly in your working treeMarkdown report, JSON report, and a reviewable worktree — no commit, no push
ReviewSame context that wrote the code approves itA separate reviewer role and final judge evaluate the diff
+
+ +

Loop Engineer vs. Codex CLI

+

+ Codex CLI is one of Loop Engineer's providers, not a competitor. Used directly, Codex CLI + gives you a capable single-agent session. Loop Engineer assigns Codex CLI (and Claude Code) + to specific roles inside a controlled loop: +

+
    +
  • + You can mix providers per role — for example Codex as implementer and Claude as reviewer — + so the code is never approved by the model that wrote it. +
  • +
  • + Each role gets the minimum permission it needs: read-only for analysis and review, + worktree-write for implementation. +
  • +
  • + A local, deterministic command runner — not the model — executes your test suite and + reports results into the loop. +
  • +
  • + Provider-signaled session limits are detected and classified as provider unavailability, + so a run fails cleanly instead of silently degrading. +
  • +
+ +

When a plain session is the better tool

+

+ For a quick question, a one-line fix, or exploratory work, a direct Claude Code or Codex + session is faster and entirely sufficient. Loop Engineer earns its overhead when a task + needs implementation + and verification: a feature with tests, a refactor that must keep the suite green, + or changes you want reviewed before they touch your branch. +

+

+ Next: read how the loop works or the + FAQ. +

+
+ + + diff --git a/site/documentation.html b/site/documentation.html new file mode 100644 index 0000000..dcac5ad --- /dev/null +++ b/site/documentation.html @@ -0,0 +1,154 @@ + + + + + + Loop Engineer Documentation – Guides and References + + + + + + + + + + + +
+ +
+
+

Documentation

+

+ The complete documentation is maintained in the repository next to the code it describes. + These are the main guides: +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GuideCovers
+ Architecture + Components, trust boundaries, and data flow
+ Workflow + State machine, loops, and stop conditions
+ GUI + Local dashboard and provider connections
+ Providers + Claude Code, Codex, and the local runner
+ Configuration + Schema, commands, and quality gates
+ Security + Process, prompt, credential, and Git controls
+ Development + Build, test, and contribution workflow
+ Roadmap + Planned scope and exclusions
+
+

+ On this site: Installation, + How it works, Security, + Comparison, and the FAQ. +

+
+ + + diff --git a/site/faq.html b/site/faq.html new file mode 100644 index 0000000..b24ec79 --- /dev/null +++ b/site/faq.html @@ -0,0 +1,220 @@ + + + + + + Loop Engineer FAQ – Providers, API Keys, Safety, and Installation + + + + + + + + + + + + +
+ +
+
+

Frequently asked questions

+ +

What is Loop Engineer?

+

+ Loop Engineer is an open-source, local-first multi-agent software-engineering orchestrator + for Claude Code, Codex CLI, and isolated Git worktrees. It assigns AI agents and local tools + to controlled roles — analyst, planner, implementer, tester, reviewer, fixer, and final + judge — and runs them as one development loop with explicit quality gates. +

+ +

How does Loop Engineer work?

+

+ A run moves through analyze → plan → implement → test → review → decide, with a + bounded fix loop when tests fail or the review finds blocking issues. Read-only roles + inspect the repository, writing roles edit an isolated Git worktree, and a local command + runner executes allowlisted test commands. Details: + how it works. +

+ +

Is Loop Engineer open source?

+

+ Yes. Loop Engineer is MIT-licensed and developed openly on + GitHub. Issues, discussions, + and contributions are welcome. +

+ +

Does Loop Engineer require API keys?

+

+ No. Loop Engineer drives the official Claude Code and Codex CLIs that are already installed + and authenticated on your machine. It has no API-key, password, OAuth-code, or token field + anywhere. Provider login — including API-key, SSO, device-code, and enterprise flows — + happens inside each official CLI, never inside Loop Engineer. +

+ +

Loop Engineer vs. a normal Claude Code session

+

+ A normal session is one agent, one permission level, and one growing chat. Loop Engineer + separates analysis, implementation, testing, and review into distinct roles with minimal + permissions, validates every handoff, and lets objective gates — not the model — decide when + work is done. See the full comparison. +

+ +

Loop Engineer vs. Codex CLI

+

+ Codex CLI is a provider inside Loop Engineer, not a competitor. Loop Engineer adds role + assignment, worktree isolation, a deterministic local tester, and cross-provider review on + top of it — for example Codex writes the code and Claude reviews it. See + Loop Engineer vs. Codex CLI. +

+ +

+ How does Loop Engineer isolate code changes? +

+

+ Writing agents edit only a detached Git worktree under + .loop-engineer/worktrees/. Loop Engineer never commits, never pushes, and never + force-resets. You inspect the worktree diff and decide what reaches your branch. + loopeng clean removes only managed worktrees and preserves dirty ones unless + forced. +

+ +

+ Which AI providers does Loop Engineer support? +

+

+ Claude Code (Anthropic) and Codex CLI (OpenAI) as AI providers, plus a local + predefined-command runner for tests. Providers are configured per role, so you can mix them + freely within one run. +

+ +

Is Loop Engineer safe?

+

+ Loop Engineer enforces explicit security boundaries: read-only roles for analysis and + review, worktree-isolated writes, shell-free allowlisted commands, a credential-free + loopback-only dashboard, prompt fencing for repository content, and secret redaction in logs + and reports. It is honest about limits too: redaction cannot recognize every custom secret + format, and no firewall can prove provider behavior. Read the + security model. +

+ +

How do I install Loop Engineer?

+

+ Install Node.js 20+, Git, and at least one provider CLI, then clone the repository, run + npm ci && npm run build && npm link, and use + loopeng init, loopeng doctor, and loopeng gui inside + your Git repository. Full steps: installation guide. +

+
+ + + diff --git a/site/how-it-works.html b/site/how-it-works.html new file mode 100644 index 0000000..c28b77d --- /dev/null +++ b/site/how-it-works.html @@ -0,0 +1,166 @@ + + + + + + How Loop Engineer Works – Roles, Workflow, and Quality Gates + + + + + + + + + + + +
+ +
+
+

How Loop Engineer works

+

+ Loop Engineer runs a state machine, not a conversation. Each phase has one role, one + provider, and one narrow contract. +

+ +

The controlled loop

+
ANALYZE → PLAN → IMPLEMENT → TEST → REVIEW → DECIDE
+                      ↑                    |
+                      └──────── FIX ←──────┘
+

+ A run starts from the current commit of your repository. The analyst and planner inspect the + code with read-only access. The implementer writes changes into an isolated Git worktree. + The tester runs your configured commands — build, test, lint, typecheck — without a shell. + The reviewer inspects the diff, and the final judge decides whether the quality gates are + satisfied. If tests fail or the review finds blocking issues, the fixer gets a bounded + number of correction cycles. +

+ +

Roles and access

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleDefault providerAccess
AnalystClaude Code or CodexRead-only
PlannerClaude Code or CodexRead-only
ImplementerClaude Code or CodexWorktree write
TesterLocal command runnerPredefined commands
ReviewerClaude Code or CodexRead-only
FixerClaude Code or CodexWorktree write
Final judgeClaude Code or CodexRead-only
+
+ +

Validated handoffs

+

+ Roles exchange structured JSON that is validated with Zod schemas before it becomes workflow + state. Raw chat transcripts never drive the loop. Repository content enters prompts inside + untrusted-data fences, so file contents are treated as data rather than instructions. +

+ +

Stop conditions

+

The orchestrator stops when one of the following happens:

+
    +
  • All quality gates pass (tests green, no blocking review findings).
  • +
  • The configured cycle or runtime limit expires.
  • +
  • Progress stalls between cycles.
  • +
  • A provider becomes unavailable, for example a session limit.
  • +
  • You cancel the run.
  • +
+ +

What you get at the end

+
.loop-engineer/
+├── runs/<run-id>/
+│   ├── report.md
+│   ├── report.json
+│   ├── task.md
+│   ├── config.snapshot.yml
+│   └── validated handoffs and provider events
+└── worktrees/<run-id>/
+    └── generated changes for human review
+

+ Nothing is committed and nothing is pushed. You inspect the worktree, take what you want, + and discard the rest with + loopeng clean. Continue with the security model or + the comparison with plain agent sessions. +

+
+ + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..9505b17 --- /dev/null +++ b/site/index.html @@ -0,0 +1,149 @@ + + + + + + Loop Engineer – Local-first Multi-Agent Orchestrator for Claude Code and Codex + + + + + + + + + + + + +
+ +
+
+
+

Local-first multi-agent orchestration for Claude Code and Codex CLI

+

+ Loop Engineer is an open-source, local-first multi-agent software-engineering orchestrator + for Claude Code, Codex CLI, and isolated Git worktrees. It assigns each AI agent one + narrow role, validates every handoff, and keeps generated changes out of your branch until + you approve them. +

+ +
+ +

What is Loop Engineer?

+

+ Loop Engineer coordinates the official Claude Code and + OpenAI Codex CLIs together with local test tooling in one controlled + development loop. Instead of a single long agent chat that mixes discovery, implementation, + testing, and approval, Loop Engineer splits those responsibilities into roles with narrow + contracts: analyst, planner, implementer, tester, reviewer, fixer, and final judge. +

+
    +
  • + Local-first + Runs entirely on your machine. No cloud account, no API keys, no telemetry. Provider login + stays inside the official CLIs. +
  • +
  • + Isolated Git worktrees + Writing agents edit only a detached worktree. Loop Engineer never commits and never + pushes. +
  • +
  • + Objective quality gates + Tests and review findings decide when the loop stops — not the model's self-assessment. +
  • +
  • + Validated handoffs + Roles exchange structured, schema-validated JSON instead of raw chat transcripts. +
  • +
+ +

How does Loop Engineer work?

+
ANALYZE → PLAN → IMPLEMENT → TEST → REVIEW → DECIDE
+                      ↑                    |
+                      └──────── FIX ←──────┘
+

+ Read-only roles inspect the repository, writing roles edit an isolated worktree, and a local + command runner executes only allowlisted test commands. Every run ends with a Markdown + report, a JSON report, and a reviewable worktree. Read the full + workflow description. +

+ +

Get started

+
git clone https://github.com/BotondCsereklye/LoopEngineer.git
+cd LoopEngineer
+npm ci && npm run build && npm link
+
+loopeng init
+loopeng doctor
+loopeng gui
+

+ See the installation guide for requirements and provider + setup, or jump straight to the FAQ. +

+ +
+ Loop Engineer is an unofficial open-source project. OpenAI and Anthropic do not sponsor or + endorse it. Review generated code and provider output before using either. +
+
+ + + diff --git a/site/installation.html b/site/installation.html new file mode 100644 index 0000000..9349bfe --- /dev/null +++ b/site/installation.html @@ -0,0 +1,118 @@ + + + + + + Install Loop Engineer – Requirements and Setup Guide + + + + + + + + + + + +
+ +
+
+

Install Loop Engineer

+

+ Loop Engineer runs locally on macOS, Linux, and Windows. It needs Node.js, Git, and at least + one supported official provider CLI. +

+ +

Requirements

+ + +

Install from source

+
git clone https://github.com/BotondCsereklye/LoopEngineer.git
+cd LoopEngineer
+npm ci
+npm run build
+npm link
+

+ npm link puts the loopeng command on your PATH. An npm package + (npm install -g loop-engineer) is planned; until it ships, installing from + source is the supported path. +

+ +

Set up a project

+

Run these commands inside a Git repository with at least one commit:

+
loopeng init      # writes loop-engineer.yml and detects project commands
+loopeng doctor    # checks Node.js, Git, providers, and write access
+loopeng gui       # opens the local dashboard at http://127.0.0.1:4317
+

+ loopeng doctor verifies Node.js, Git, worktree support, repository state, + provider installation and authentication, command detection, instruction files, and write + access — so problems surface before your first run. +

+ +

Connect providers

+

+ The dashboard can start claude auth login --claudeai or + codex login. Each official CLI owns its browser flow, callback, and credential + store. Loop Engineer receives only installed-and-authenticated status — it has no password, + API-key, OAuth-code, or access-token field anywhere. +

+ +

Run your first task

+
loopeng run --dry-run --task "Add input validation to the settings parser"
+loopeng run --task "Add input validation to the settings parser"
+

+ Start with --dry-run to preview the workflow without calling any provider. Then + read how the loop works and the + security model. +

+
+ + + diff --git a/site/llms.txt b/site/llms.txt new file mode 100644 index 0000000..b3a5591 --- /dev/null +++ b/site/llms.txt @@ -0,0 +1,24 @@ +# Loop Engineer + +> Loop Engineer is an open-source, local-first multi-agent software-engineering +> orchestrator for Claude Code, Codex CLI, and isolated Git worktrees. + +Loop Engineer coordinates the official Claude Code (Anthropic) and Codex CLI +(OpenAI) command-line tools together with local test tooling in one controlled +development loop. Each agent gets one role (analyst, planner, implementer, +tester, reviewer, fixer, final judge), every handoff is schema-validated, and +writing agents edit only an isolated Git worktree. Tests and review gates +decide when the loop stops. Loop Engineer never commits, never pushes, and +requires no API keys — provider login stays inside the official CLIs. + +License: MIT. Language: TypeScript (Node.js 20+). Version: 0.1.0. + +## Docs + +- [Homepage](https://botondcsereklye.github.io/LoopEngineer/): overview and quick start +- [How it works](https://botondcsereklye.github.io/LoopEngineer/how-it-works.html): roles, workflow, quality gates +- [Installation](https://botondcsereklye.github.io/LoopEngineer/installation.html): requirements and setup +- [Security](https://botondcsereklye.github.io/LoopEngineer/security.html): isolation, credentials, Git safety +- [Comparison](https://botondcsereklye.github.io/LoopEngineer/comparison.html): vs. plain Claude Code sessions and Codex CLI +- [FAQ](https://botondcsereklye.github.io/LoopEngineer/faq.html): common questions +- [Repository](https://github.com/BotondCsereklye/LoopEngineer): source code, issues, discussions diff --git a/site/robots.txt b/site/robots.txt new file mode 100644 index 0000000..ab6766a --- /dev/null +++ b/site/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://botondcsereklye.github.io/LoopEngineer/sitemap.xml diff --git a/site/security.html b/site/security.html new file mode 100644 index 0000000..d8e2a93 --- /dev/null +++ b/site/security.html @@ -0,0 +1,133 @@ + + + + + + Loop Engineer Security Model – Isolation, Credentials, and Git Safety + + + + + + + + + + + +
+ +
+
+

Security model

+

+ Loop Engineer is built around explicit boundaries: what each agent can read, what it can + write, which commands can run, and what never leaves your machine. +

+ +

How Loop Engineer isolates code changes

+
    +
  • + Writing roles (implementer, fixer) edit only a managed, detached Git worktree under + .loop-engineer/worktrees/. +
  • +
  • + Analyst, planner, reviewer, and final judge run with read-only provider permissions. +
  • +
  • + Loop Engineer never commits, never pushes, never force-resets, and never runs a + destructive clean command. +
  • +
  • + loopeng clean removes only marked managed worktrees and preserves dirty + worktrees unless you pass --force. +
  • +
+ +

Command execution

+

+ The local tester runs only configured allowlisted commands without a shell. It rejects + chaining, pipes, redirects, command substitution, denied binaries, and destructive Git + commands. The configuration schema rejects unknown keys, invalid permissions, and unsafe + tester assignments. +

+ +

Credentials

+

+ Provider login stays inside the official Claude Code and Codex CLIs. The local dashboard can + only start those login flows; it has no password, API-key, OAuth-code, or access-token + field, and the dashboard API never handles credentials. The GUI binds to loopback only, uses + a CSRF token, and ships a strict Content Security Policy. +

+ +

Prompt and data handling

+
    +
  • + Repository content enters prompts inside untrusted-data fences, so file contents are + treated as data rather than instructions. +
  • +
  • Logs and reports redact common key, token, and password formats before storage.
  • +
  • Structured role handoffs are schema-validated before they become workflow state.
  • +
+ +

Honest limitations

+
+ Redaction cannot recognize every custom secret format. Keep + .loop-engineer/ private and treat run reports like build logs. The context + firewall and redactor reduce risk; they cannot prove provider behavior. Review generated + changes before merging them. +
+

+ The complete security documentation, including the threat model and reporting process, lives + in the repository: + docs/security.md + and + SECURITY.md. +

+
+ + + diff --git a/site/sitemap.xml b/site/sitemap.xml new file mode 100644 index 0000000..7cbffca --- /dev/null +++ b/site/sitemap.xml @@ -0,0 +1,35 @@ + + + + https://botondcsereklye.github.io/LoopEngineer/ + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/how-it-works.html + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/installation.html + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/security.html + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/comparison.html + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/faq.html + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/changelog.html + 2026-07-15 + + + https://botondcsereklye.github.io/LoopEngineer/documentation.html + 2026-07-15 + + diff --git a/src/config/defaults.ts b/src/config/defaults.ts index b0c25dd..2863053 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -26,13 +26,28 @@ export function defaultConfig(): LoopEngineerConfig { require_human_approval_before_apply: false, }, roles: { - analyst: { provider: 'codex', model: 'default', permissions: 'read-only' }, - planner: { provider: 'claude', model: 'default', permissions: 'read-only' }, - implementer: { provider: 'codex', model: 'default', permissions: 'workspace-write' }, - reviewer: { provider: 'claude', model: 'default', permissions: 'read-only' }, + analyst: { provider: 'codex', model: 'default', effort: 'auto', permissions: 'read-only' }, + planner: { provider: 'claude', model: 'default', effort: 'auto', permissions: 'read-only' }, + implementer: { + provider: 'codex', + model: 'default', + effort: 'auto', + permissions: 'workspace-write', + }, + reviewer: { provider: 'claude', model: 'default', effort: 'auto', permissions: 'read-only' }, tester: { provider: 'local', model: 'default', permissions: 'predefined-commands' }, - fixer: { provider: 'codex', model: 'default', permissions: 'workspace-write' }, - final_judge: { provider: 'claude', model: 'default', permissions: 'read-only' }, + fixer: { + provider: 'codex', + model: 'default', + effort: 'auto', + permissions: 'workspace-write', + }, + final_judge: { + provider: 'claude', + model: 'default', + effort: 'auto', + permissions: 'read-only', + }, }, quality_gates: { require_tests_pass: true, @@ -79,21 +94,25 @@ roles: analyst: provider: codex model: default + effort: auto permissions: read-only planner: provider: claude model: default + effort: auto permissions: read-only implementer: provider: codex model: default + effort: auto permissions: workspace-write reviewer: provider: claude model: default + effort: auto permissions: read-only tester: @@ -103,11 +122,13 @@ roles: fixer: provider: codex model: default + effort: auto permissions: workspace-write final_judge: provider: claude model: default + effort: auto permissions: read-only quality_gates: diff --git a/src/config/schema.ts b/src/config/schema.ts index 7debcad..dc619d7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { PERMISSION_MODES, PROVIDER_IDS, SEVERITIES } from '../domain/types.js'; +import { REASONING_EFFORTS } from '../providers/catalog.js'; const providerId = z.enum(PROVIDER_IDS); const permissionMode = z.enum(PERMISSION_MODES); @@ -9,6 +10,7 @@ const roleConfigSchema = z .object({ provider: providerId, model: z.string().min(1).default('default'), + effort: z.enum(REASONING_EFFORTS).optional(), permissions: permissionMode, }) .strict(); diff --git a/src/gui/public/app.js b/src/gui/public/app.js index eb6a594..75fc1e3 100644 --- a/src/gui/public/app.js +++ b/src/gui/public/app.js @@ -9,16 +9,23 @@ const ROLE_META = { final_judge: ['Final judgement', 'read-only'], }; -const state = { csrfToken: '', polling: null }; +const state = { + csrfToken: '', + polling: null, + providerPolling: null, + modelCatalog: null, + connections: [], + lastSnapshot: { status: 'idle', events: [] }, +}; const $ = (id) => document.getElementById(id); document.addEventListener('DOMContentLoaded', () => { setupTheme(); - renderRoleSkeletons(); $('run-form').addEventListener('submit', startRun); $('cancel-button').addEventListener('click', cancelRun); $('task').addEventListener('input', updateTaskCount); $('close-report').addEventListener('click', () => $('report-dialog').close()); + $('refresh-providers').addEventListener('click', () => void refreshProviders()); void bootstrap(); }); @@ -26,10 +33,13 @@ async function bootstrap() { try { const data = await api('/api/bootstrap'); state.csrfToken = data.csrfToken; + state.modelCatalog = data.modelCatalog; $('root-path').textContent = data.root; + renderRoleSelectors(); fillConfig(data.config); renderDoctor(data.doctor); renderReports(data.reports); + await refreshProviders(); setConnection('online', 'Connected locally'); const snapshot = await api('/api/run'); renderRun(snapshot); @@ -40,7 +50,118 @@ async function bootstrap() { } } -function renderRoleSkeletons() { +async function refreshProviders() { + try { + const connections = await api('/api/providers'); + state.connections = connections; + renderProviderConnections(connections); + renderRunProviders(state.lastSnapshot); + showProviderError(''); + if (connections.some((connection) => connection.state === 'connecting')) { + startProviderPolling(); + } else { + stopProviderPolling(); + } + } catch (error) { + showProviderError(messageOf(error)); + } +} + +function renderProviderConnections(connections) { + const grid = $('provider-grid'); + grid.replaceChildren(); + for (const connection of connections) { + const card = document.createElement('article'); + card.className = 'provider-connection-card'; + + const header = document.createElement('div'); + header.className = 'provider-connection-header'; + const identity = document.createElement('div'); + identity.className = 'provider-identity'; + const mark = document.createElement('span'); + mark.className = `provider-mark ${connection.id}`; + mark.textContent = connection.id === 'claude' ? 'C' : 'O'; + const name = document.createElement('div'); + const title = document.createElement('h3'); + title.textContent = connection.label; + const version = document.createElement('span'); + version.textContent = + connection.version || (connection.installed ? 'Installed' : 'Not installed'); + name.append(title, version); + identity.append(mark, name); + const badge = document.createElement('span'); + badge.className = `provider-state ${connection.state}`; + badge.textContent = providerStateLabel(connection.state); + header.append(identity, badge); + + const details = document.createElement('p'); + details.textContent = connection.details; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'button secondary provider-connect-button'; + button.textContent = providerButtonLabel(connection); + button.disabled = + !connection.installed || + connection.state === 'connecting' || + connection.state === 'connected'; + button.addEventListener('click', () => void connectProvider(connection.id, button)); + + card.append(header, details, button); + grid.append(card); + } +} + +async function connectProvider(provider, button) { + showProviderError(''); + button.disabled = true; + button.textContent = 'Opening official sign-in …'; + try { + const result = await api(`/api/providers/${encodeURIComponent(provider)}/connect`, { + method: 'POST', + body: {}, + }); + if (result.status === 'already-connected') { + await refreshProviders(); + return; + } + await refreshProviders(); + startProviderPolling(); + } catch (error) { + showProviderError(messageOf(error)); + await refreshProviders(); + } +} + +function startProviderPolling() { + if (state.providerPolling !== null) return; + state.providerPolling = window.setInterval(() => void refreshProviders(), 1_000); +} + +function stopProviderPolling() { + if (state.providerPolling !== null) window.clearInterval(state.providerPolling); + state.providerPolling = null; +} + +function providerStateLabel(providerState) { + return ( + { + connected: 'Connected', + disconnected: 'Sign-in required', + connecting: 'Signing in', + unavailable: 'Unavailable', + unknown: 'Check required', + }[providerState] || providerState + ); +} + +function providerButtonLabel(connection) { + if (!connection.installed) return 'Install CLI first'; + if (connection.state === 'connected') return 'Connected'; + if (connection.state === 'connecting') return 'Complete in browser'; + return connection.id === 'claude' ? 'Sign in with Claude' : 'Sign in with OpenAI'; +} + +function renderRoleSelectors() { const grid = $('roles-grid'); grid.replaceChildren(); for (const [role, [label, permission]] of Object.entries(ROLE_META)) { @@ -58,42 +179,111 @@ function renderRoleSkeletons() { const controls = document.createElement('div'); controls.className = 'role-controls'; controls.append( - labeledSelect(`role-${role}-provider`, 'Provider', ['claude', 'codex']), - labeledInput(`role-${role}-model`, 'Model', 'default'), + emptySelect(`role-${role}-provider`, 'Provider'), + emptySelect(`role-${role}-model`, 'Model'), + emptySelect(`role-${role}-effort`, 'Intelligence'), ); card.append(header, controls); grid.append(card); + + setSelectOptions( + $(`role-${role}-provider`), + Object.entries(state.modelCatalog).map(([id, catalog]) => ({ + value: id, + label: catalog.label, + })), + ); + $(`role-${role}-provider`).addEventListener('change', () => refreshRoleSelectors(role)); + $(`role-${role}-model`).addEventListener('change', () => refreshEffortSelector(role)); + refreshRoleSelectors(role); } } -function labeledSelect(id, text, values) { +function emptySelect(id, text) { const label = document.createElement('label'); label.htmlFor = id; label.append(document.createTextNode(text)); const select = document.createElement('select'); select.id = id; + label.append(select); + return label; +} + +function setSelectOptions(select, values) { + select.replaceChildren(); for (const value of values) { const option = document.createElement('option'); - option.value = value; - option.textContent = value === 'claude' ? 'Claude Code' : 'Codex CLI'; + option.value = value.value; + option.textContent = value.label; + if (value.description) option.title = value.description; select.append(option); } - label.append(select); - return label; } -function labeledInput(id, text, value) { - const label = document.createElement('label'); - label.htmlFor = id; - label.append(document.createTextNode(text)); - const input = document.createElement('input'); - input.id = id; - input.type = 'text'; - input.maxLength = 100; - input.value = value; - input.spellcheck = false; - label.append(input); - return label; +function refreshRoleSelectors(role, preferredModel, preferredEffort) { + const provider = $(`role-${role}-provider`).value; + const catalog = state.modelCatalog[provider]; + const modelSelect = $(`role-${role}-model`); + setSelectOptions( + modelSelect, + catalog.models.map((model) => ({ + value: model.id, + label: model.label, + description: model.description, + })), + ); + const model = catalog.models.some((entry) => entry.id === preferredModel) + ? preferredModel + : catalog.defaultModel; + modelSelect.value = model; + refreshEffortSelector(role, preferredEffort); +} + +function refreshEffortSelector(role, preferredEffort) { + const provider = $(`role-${role}-provider`).value; + const model = $(`role-${role}-model`).value; + const modelOption = state.modelCatalog[provider].models.find((entry) => entry.id === model); + if (!modelOption) return; + const effortSelect = $(`role-${role}-effort`); + setSelectOptions( + effortSelect, + modelOption.efforts.map((effort) => ({ value: effort, label: effortLabel(effort) })), + ); + effortSelect.value = modelOption.efforts.includes(preferredEffort) + ? preferredEffort + : modelOption.defaultEffort; + effortSelect.title = effortDescription(effortSelect.value); + effortSelect.onchange = () => { + effortSelect.title = effortDescription(effortSelect.value); + }; +} + +function effortLabel(effort) { + return ( + { + auto: 'Automatic', + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra High', + max: 'Max', + ultra: 'Ultra', + }[effort] || effort + ); +} + +function effortDescription(effort) { + return ( + { + auto: 'Use the provider and model default.', + low: 'Fast responses with lighter reasoning.', + medium: 'Balanced speed and reasoning depth.', + high: 'Deeper reasoning for complex tasks.', + xhigh: 'Extra-high reasoning depth.', + max: 'Maximum single-agent reasoning depth.', + ultra: 'Maximum reasoning with automatic task delegation.', + }[effort] || '' + ); } function fillConfig(config) { @@ -108,7 +298,7 @@ function fillConfig(config) { for (const role of Object.keys(ROLE_META)) { $(`role-${role}-provider`).value = config.roles[role].provider === 'claude' ? 'claude' : 'codex'; - $(`role-${role}-model`).value = config.roles[role].model; + refreshRoleSelectors(role, config.roles[role].model, config.roles[role].effort || 'auto'); } for (const name of ['build', 'test', 'lint', 'typecheck']) { $(`command-${name}`).value = config.commands[name] || ''; @@ -120,7 +310,8 @@ function collectRequest() { for (const role of Object.keys(ROLE_META)) { roles[role] = { provider: $(`role-${role}-provider`).value, - model: $(`role-${role}-model`).value.trim(), + model: $(`role-${role}-model`).value, + effort: $(`role-${role}-effort`).value, }; } return { @@ -196,6 +387,7 @@ function stopPolling() { } function renderRun(snapshot) { + state.lastSnapshot = snapshot; const labels = { idle: 'Ready', running: 'Running', @@ -207,6 +399,9 @@ function renderRun(snapshot) { badge.textContent = labels[snapshot.status] || snapshot.status; badge.className = `badge ${snapshot.status}`; setRunningControls(snapshot.status === 'running'); + renderRunProviders(snapshot); + renderRunIssue(snapshot); + renderActiveAgent(snapshot); const summary = $('run-summary'); summary.replaceChildren(); @@ -216,7 +411,8 @@ function renderRun(snapshot) { } else { summary.className = 'run-result'; const title = document.createElement('strong'); - title.textContent = snapshot.result?.report?.task || `Run ${snapshot.id?.slice(0, 8) || ''}`; + title.textContent = + snapshot.result?.report?.task || snapshot.task || `Run ${snapshot.id?.slice(0, 8) || ''}`; const detail = document.createElement('span'); detail.textContent = snapshot.error || resultDetail(snapshot); summary.append(title, detail); @@ -230,6 +426,111 @@ function renderRun(snapshot) { } } +function renderRunProviders(snapshot) { + const strip = $('run-provider-strip'); + strip.replaceChildren(); + for (const provider of ['claude', 'codex']) { + const connection = state.connections.find((item) => item.id === provider); + if (!connection) continue; + const item = document.createElement('span'); + const limited = snapshot.issue?.provider === provider; + item.className = `run-provider ${limited ? 'limited' : connection.state}`; + const dot = document.createElement('span'); + dot.className = 'mini-dot'; + const label = document.createElement('span'); + label.textContent = `${provider === 'claude' ? 'Claude' : 'Codex'} · ${ + limited ? issueShortLabel(snapshot.issue.kind) : providerStateLabel(connection.state) + }`; + item.append(dot, label); + strip.append(item); + } +} + +function renderRunIssue(snapshot) { + const issueCard = $('run-issue'); + issueCard.replaceChildren(); + if (!snapshot.issue) { + issueCard.hidden = true; + return; + } + issueCard.hidden = false; + const title = document.createElement('strong'); + title.textContent = issueTitle(snapshot.issue.kind); + const meta = document.createElement('span'); + const provider = snapshot.issue.provider === 'claude' ? 'Claude Code' : 'OpenAI Codex'; + const role = snapshot.issue.role ? ` · ${roleLabel(snapshot.issue.role)}` : ''; + meta.textContent = `${provider}${role}`; + const detail = document.createElement('p'); + detail.textContent = snapshot.issue.resetAt + ? `Available again after ${snapshot.issue.resetAt}. Choose the other provider for this role or retry later.` + : 'This provider cannot continue. Check its sign-in and usage status or choose the other provider.'; + issueCard.append(title, meta, detail); +} + +function renderActiveAgent(snapshot) { + const card = $('active-agent'); + card.replaceChildren(); + if (snapshot.status !== 'running' || !snapshot.active) { + card.hidden = true; + return; + } + card.hidden = false; + const header = document.createElement('div'); + const pulse = document.createElement('span'); + pulse.className = 'thinking-pulse'; + const title = document.createElement('strong'); + title.textContent = `${providerLabel(snapshot.active.provider)} is thinking`; + header.append(pulse, title); + const meta = document.createElement('span'); + meta.className = 'active-meta'; + meta.textContent = `${roleLabel(snapshot.active.role)} · ${snapshot.active.model} · ${effortLabel( + snapshot.active.effort, + )}`; + const elapsed = document.createElement('span'); + elapsed.className = 'active-elapsed'; + elapsed.textContent = `${formatElapsed(snapshot.active.startedAt)} elapsed`; + const note = document.createElement('small'); + note.textContent = + 'Safe progress metadata is shown here; private chain-of-thought is not exposed.'; + card.append(header, meta, elapsed, note); +} + +function providerLabel(provider) { + if (provider === 'claude') return 'Claude Code'; + if (provider === 'codex') return 'OpenAI Codex'; + if (provider === 'local') return 'Local checks'; + return provider; +} + +function roleLabel(role) { + return ROLE_META[role]?.[0] || role.replaceAll('_', ' '); +} + +function issueShortLabel(kind) { + return kind === 'session-limit' + ? 'Session limit' + : kind === 'authentication' + ? 'Sign-in required' + : 'Unavailable'; +} + +function issueTitle(kind) { + return kind === 'session-limit' + ? 'Session limit reached' + : kind === 'rate-limit' + ? 'Usage limit reached' + : kind === 'authentication' + ? 'Provider sign-in required' + : 'Provider unavailable'; +} + +function formatElapsed(startedAt) { + const seconds = Math.max(0, Math.floor((Date.now() - Date.parse(startedAt)) / 1_000)); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return minutes > 0 ? `${minutes}m ${String(rest).padStart(2, '0')}s` : `${rest}s`; +} + function resultDetail(snapshot) { if (snapshot.status === 'running') return 'Agents are working on the task in a controlled loop.'; if (snapshot.result?.dryRun) return 'Dry run finished — no files were changed.'; @@ -326,6 +627,12 @@ function showError(message) { field.textContent = message; } +function showProviderError(message) { + const field = $('provider-error'); + field.hidden = !message; + field.textContent = message; +} + function setConnection(status, label) { const element = $('app-status'); element.className = `connection ${status}`; diff --git a/src/gui/public/index.html b/src/gui/public/index.html index 942a35f..fd6fea9 100644 --- a/src/gui/public/index.html +++ b/src/gui/public/index.html @@ -49,6 +49,24 @@

One task. One controlled loop.

+
+
+
+ CONNECT +

Provider connections

+
+ +
+

+ Sign in through the official Claude Code or OpenAI Codex CLI. Loop Engineer never sees + or stores your password, API key, OAuth code or access token. +

+
+
Checking local provider connections …
+
+ +
+
@@ -216,6 +234,13 @@

Ready for the loop?

Current run

Ready
+
+ +
No run started yet.
    diff --git a/src/gui/public/styles.css b/src/gui/public/styles.css index 6f078f3..d45f5e2 100644 --- a/src/gui/public/styles.css +++ b/src/gui/public/styles.css @@ -298,6 +298,134 @@ a:focus-visible { color: var(--muted); font-size: 0.88rem; } +.text-button { + min-height: 38px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + color: var(--text); + font-size: 0.76rem; + font-weight: 650; +} +.text-button:hover { + border-color: var(--line-strong); +} + +.providers-card { + border-color: color-mix(in srgb, var(--info) 28%, var(--line)); +} +.provider-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.provider-placeholder { + grid-column: 1 / -1; + padding: 18px; + border: 1px dashed var(--line-strong); + border-radius: 10px; + color: var(--muted); + font-size: 0.82rem; +} +.provider-connection-card { + display: grid; + align-content: start; + gap: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); +} +.provider-connection-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.provider-identity { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} +.provider-identity > div { + min-width: 0; + display: grid; + gap: 2px; +} +.provider-identity h3 { + margin: 0; + font-size: 0.9rem; +} +.provider-identity span:not(.provider-mark) { + overflow: hidden; + color: var(--muted); + font: + 0.66rem ui-monospace, + SFMono-Regular, + Menlo, + monospace; + text-overflow: ellipsis; + white-space: nowrap; +} +.provider-mark { + width: 34px; + height: 34px; + flex: 0 0 auto; + display: grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 9px; + color: var(--text); + font-weight: 750; +} +.provider-mark.claude { + background: color-mix(in srgb, #d97757 18%, var(--surface)); +} +.provider-mark.codex { + background: color-mix(in srgb, var(--accent) 15%, var(--surface)); +} +.provider-state { + flex: 0 0 auto; + padding: 4px 7px; + border-radius: 999px; + background: var(--surface); + color: var(--muted); + font: + 700 0.59rem/1.2 ui-monospace, + SFMono-Regular, + Menlo, + monospace; + text-transform: uppercase; +} +.provider-state.connected { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); +} +.provider-state.connecting { + background: color-mix(in srgb, var(--info) 14%, transparent); + color: var(--info); +} +.provider-state.disconnected, +.provider-state.unavailable { + background: color-mix(in srgb, var(--warning) 13%, transparent); + color: var(--warning); +} +.provider-connection-card > p { + min-height: 2.7em; + margin: 0; + color: var(--muted); + font-size: 0.75rem; +} +.provider-connect-button { + width: 100%; + min-height: 42px; + font-size: 0.78rem; +} +.providers-card .form-error { + margin-top: 14px; +} label { color: var(--text); font-size: 0.86rem; @@ -364,7 +492,7 @@ input::placeholder { } .role-controls { display: grid; - grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr) minmax(0, 0.85fr); gap: 8px; } .role-controls label { @@ -587,6 +715,137 @@ input[type='checkbox'] { background: color-mix(in srgb, var(--danger) 13%, transparent); color: var(--danger); } +.run-provider-strip { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-bottom: 12px; +} +.run-provider { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font: + 650 0.61rem/1.2 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} +.mini-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--muted); +} +.run-provider.connected .mini-dot { + background: var(--accent); +} +.run-provider.connecting .mini-dot { + background: var(--info); +} +.run-provider.disconnected .mini-dot, +.run-provider.unavailable .mini-dot { + background: var(--warning); +} +.run-provider.limited { + border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); + color: var(--danger); +} +.run-provider.limited .mini-dot { + background: var(--danger); +} +.run-issue { + display: grid; + gap: 4px; + margin-bottom: 12px; + padding: 13px; + border: 1px solid color-mix(in srgb, var(--danger) 42%, var(--line)); + border-radius: 10px; + background: color-mix(in srgb, var(--danger) 8%, var(--surface-2)); +} +.run-issue[hidden] { + display: none; +} +.run-issue strong { + color: var(--danger); + font-size: 0.84rem; +} +.run-issue > span { + color: var(--muted); + font: + 0.65rem ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} +.run-issue p { + margin: 5px 0 0; + color: var(--text); + font-size: 0.74rem; +} +.active-agent { + position: relative; + display: grid; + gap: 6px; + margin-bottom: 12px; + padding: 14px; + border: 1px solid color-mix(in srgb, var(--info) 38%, var(--line)); + border-radius: 10px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--info) 9%, var(--surface-2)), + var(--surface-2) + ); +} +.active-agent[hidden] { + display: none; +} +.active-agent > div { + display: flex; + align-items: center; + gap: 9px; +} +.active-agent strong { + font-size: 0.84rem; +} +.thinking-pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--info); + box-shadow: 0 0 0 0 color-mix(in srgb, var(--info) 50%, transparent); + animation: thinking-pulse 1.5s ease-out infinite; +} +.active-meta, +.active-elapsed { + color: var(--muted); + font: + 0.66rem/1.4 ui-monospace, + SFMono-Regular, + Menlo, + monospace; + overflow-wrap: anywhere; +} +.active-elapsed { + color: var(--info); +} +.active-agent small { + color: var(--muted); + font-size: 0.66rem; + line-height: 1.35; +} +@keyframes thinking-pulse { + 70% { + box-shadow: 0 0 0 7px transparent; + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} .empty-state { padding: 18px 0; color: var(--muted); @@ -783,6 +1042,7 @@ dialog::backdrop { .sidebar > .card { padding: 20px; } + .provider-grid, .roles-grid, .choice-grid, .number-grid, @@ -790,6 +1050,9 @@ dialog::backdrop { .sidebar { grid-template-columns: 1fr; } + .role-controls { + grid-template-columns: 1fr; + } .launch-card { grid-template-columns: 1fr; padding: 20px; diff --git a/src/gui/schema.ts b/src/gui/schema.ts index 08fb0ee..0eb4334 100644 --- a/src/gui/schema.ts +++ b/src/gui/schema.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { configSchema, type LoopEngineerConfig } from '../config/schema.js'; import { SEVERITIES } from '../domain/types.js'; +import { isProviderSelectionSupported, REASONING_EFFORTS } from '../providers/catalog.js'; import { checkCommandStructure } from '../security/command-policy.js'; export const GUI_AGENT_ROLES = [ @@ -16,8 +17,18 @@ const roleSelectionSchema = z .object({ provider: z.enum(['claude', 'codex']), model: z.string().trim().min(1).max(100).default('default'), + effort: z.enum(REASONING_EFFORTS), }) - .strict(); + .strict() + .superRefine((selection, context) => { + if (!isProviderSelectionSupported(selection.provider, selection.model, selection.effort)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['model'], + message: `Model "${selection.model}" with intelligence "${selection.effort}" is not supported by ${selection.provider}`, + }); + } + }); const commandSchema = z .string() @@ -86,6 +97,7 @@ export function buildRunConfig( ...baseConfig.roles[role], provider: request.roles[role].provider, model: request.roles[role].model, + effort: request.roles[role].effort, }, ]), ) as Pick; @@ -104,6 +116,7 @@ export function buildRunConfig( ...baseConfig.roles.tester, provider: 'local', model: 'default', + effort: undefined, permissions: 'predefined-commands', }, }, diff --git a/src/gui/server.ts b/src/gui/server.ts index 375b3b4..4e37bce 100644 --- a/src/gui/server.ts +++ b/src/gui/server.ts @@ -4,11 +4,18 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import type { AddressInfo } from 'node:net'; import type { LoopEngineerConfig } from '../config/schema.js'; import type { DoctorCheck } from '../cli/commands/doctor.js'; -import type { Logger } from '../logging/logger.js'; +import type { Logger, RunActivity } from '../logging/logger.js'; import type { OrchestratorResult } from '../workflow/orchestrator.js'; import type { RunReport } from '../reports/types.js'; import { redactDeep, redactSecrets } from '../security/secret-redactor.js'; +import { + isProviderId, + type ProviderConnection, + type ProviderConnectResult, + type ProviderId, +} from '../providers/auth.js'; import { guiRunRequestSchema, type GuiRunRequest } from './schema.js'; +import { PROVIDER_MODEL_CATALOG, type ProviderModelCatalogEntry } from '../providers/catalog.js'; const MAX_BODY_BYTES = 256 * 1024; const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1']); @@ -18,12 +25,16 @@ export interface GuiBootstrap { config: LoopEngineerConfig; doctor: DoctorCheck[]; reports: RunReport[]; + modelCatalog: Record<'claude' | 'codex', ProviderModelCatalogEntry>; } export interface GuiServices { bootstrap(): Promise; run(request: GuiRunRequest, signal: AbortSignal, logger: Logger): Promise; report(runId: string): Promise; + providerConnections(): Promise; + connectProvider(provider: ProviderId): Promise; + close?(): Promise; } export interface GuiServerOptions { @@ -48,6 +59,17 @@ interface GuiRunSnapshot { events: string[]; result?: OrchestratorResult; error?: string; + task?: string; + active?: RunActivity; + lastActivity?: RunActivity; + issue?: GuiProviderIssue; +} + +export interface GuiProviderIssue { + kind: 'session-limit' | 'rate-limit' | 'authentication' | 'unavailable'; + provider?: string; + role?: string; + resetAt?: string; } interface Asset { @@ -93,13 +115,21 @@ export async function createGuiServer(options: GuiServerOptions): Promise { + const safeMessage = redactSecrets( + error instanceof Error ? error.message : String(error), + ).slice(0, 1_000); snapshot = { ...snapshot, status: controller.signal.aborted ? 'cancelled' : 'failed', finishedAt: new Date().toISOString(), - error: redactSecrets(error instanceof Error ? error.message : String(error)).slice( - 0, - 1_000, - ), + error: safeMessage, + active: undefined, + issue: classifyProviderIssue(safeMessage), }; }) .finally(() => { @@ -183,6 +224,20 @@ export async function createGuiServer(options: GuiServerOptions): Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); @@ -226,6 +282,25 @@ export async function createGuiServer(options: GuiServerOptions): Promise> { const definitions = [ ['/', 'index.html', 'text/html; charset=utf-8'], diff --git a/src/gui/services.ts b/src/gui/services.ts index c5cd95c..b362b40 100644 --- a/src/gui/services.ts +++ b/src/gui/services.ts @@ -5,9 +5,11 @@ import { loadConfig } from '../config/loader.js'; import { ConfigurationError } from '../domain/errors.js'; import { isGitRepository, repositoryRoot } from '../git/repository.js'; import { createDefaultRegistry } from '../providers/registry.js'; +import { ProviderAuthManager } from '../providers/auth.js'; import { orchestrate } from '../workflow/orchestrator.js'; import { buildRunConfig } from './schema.js'; import type { GuiServices } from './server.js'; +import { PROVIDER_MODEL_CATALOG } from '../providers/catalog.js'; export async function createDefaultGuiServices( cwd: string, @@ -20,11 +22,12 @@ export async function createDefaultGuiServices( throw new ConfigurationError('Project root is not a Git repository'); } const root = await repositoryRoot(configuredRoot); + const auth = new ProviderAuthManager({ cwd: root }); return { async bootstrap() { const [doctor, reports] = await Promise.all([runDoctor(root), listRunReports(root)]); - return { root, config: baseConfig, doctor, reports }; + return { root, config: baseConfig, doctor, reports, modelCatalog: PROVIDER_MODEL_CATALOG }; }, async run(request, signal, logger) { return orchestrate({ @@ -40,5 +43,14 @@ export async function createDefaultGuiServices( report(runId) { return readRunReport(root, runId); }, + providerConnections() { + return auth.connections(); + }, + connectProvider(provider) { + return auth.connect(provider); + }, + close() { + return auth.close(); + }, }; } diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 62bc627..1ce6ae7 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -1,8 +1,21 @@ import { redactSecrets } from '../security/secret-redactor.js'; +import type { RoleName } from '../domain/types.js'; +import type { ReasoningEffort } from '../providers/catalog.js'; + +export interface RunActivity { + state: 'thinking' | 'completed' | 'failed'; + role: RoleName; + provider: string; + model: string; + effort: ReasoningEffort; + startedAt: string; + finishedAt?: string; +} export interface Logger { info(message: string): void; warn(message: string): void; + activity?(activity: RunActivity): void; } export const silentLogger: Logger = { info() {}, warn() {} }; diff --git a/src/providers/auth.ts b/src/providers/auth.ts new file mode 100644 index 0000000..e394bd3 --- /dev/null +++ b/src/providers/auth.ts @@ -0,0 +1,152 @@ +import { ConfigurationError } from '../domain/errors.js'; +import { runProcess } from '../execution/process-runner.js'; +import { ClaudeProvider } from './claude/adapter.js'; +import { CodexProvider } from './codex/adapter.js'; + +export const PROVIDER_IDS = ['claude', 'codex'] as const; +export type ProviderId = (typeof PROVIDER_IDS)[number]; +export type ProviderConnectionState = + 'connected' | 'disconnected' | 'connecting' | 'unavailable' | 'unknown'; + +export interface ProviderConnection { + id: ProviderId; + label: string; + installed: boolean; + authenticated?: boolean; + version?: string; + state: ProviderConnectionState; + details: string; +} + +export interface ProviderConnectResult { + provider: ProviderId; + status: 'started' | 'already-connected' | 'in-progress'; +} + +export interface ProviderAuthManagerOptions { + cwd: string; + claudeBinary?: string; + codexBinary?: string; + timeoutMs?: number; +} + +interface ActiveLogin { + controller: AbortController; + completion: Promise; +} + +const LABELS: Record = { + claude: 'Claude Code', + codex: 'OpenAI Codex', +}; + +const LOGIN_ARGS: Record = { + claude: ['auth', 'login', '--claudeai'], + codex: ['login'], +}; + +/** + * Delegates authentication to the installed official CLIs. It never reads, + * stores, returns, or logs credentials and never starts a shell. + */ +export class ProviderAuthManager { + private readonly cwd: string; + private readonly binaries: Record; + private readonly timeoutMs: number; + private readonly active = new Map(); + private readonly failed = new Set(); + + constructor(options: ProviderAuthManagerOptions) { + this.cwd = options.cwd; + this.binaries = { + claude: options.claudeBinary ?? 'claude', + codex: options.codexBinary ?? 'codex', + }; + this.timeoutMs = options.timeoutMs ?? 10 * 60_000; + } + + async connections(): Promise { + return Promise.all(PROVIDER_IDS.map((provider) => this.connection(provider))); + } + + async connect(provider: ProviderId): Promise { + assertProviderId(provider); + const active = this.active.get(provider); + if (active) return { provider, status: 'in-progress' }; + + const connection = await this.probe(provider); + if (!connection.installed) { + throw new ConfigurationError(`${LABELS[provider]} CLI is not installed`); + } + if (connection.authenticated === true) { + return { provider, status: 'already-connected' }; + } + + this.failed.delete(provider); + const controller = new AbortController(); + const completion = runProcess({ + command: this.binaries[provider], + args: LOGIN_ARGS[provider], + cwd: this.cwd, + timeoutMs: this.timeoutMs, + signal: controller.signal, + maxOutputBytes: 64 * 1024, + }) + .then((result) => { + if (result.exitCode !== 0 || result.timedOut) this.failed.add(provider); + }) + .catch(() => { + if (!controller.signal.aborted) this.failed.add(provider); + }) + .finally(() => { + this.active.delete(provider); + }); + this.active.set(provider, { controller, completion }); + return { provider, status: 'started' }; + } + + async close(): Promise { + const active = [...this.active.values()]; + for (const login of active) login.controller.abort(); + await Promise.allSettled(active.map((login) => login.completion)); + } + + private async connection(provider: ProviderId): Promise { + const availability = await this.probe(provider); + if (this.active.has(provider)) { + return { + ...availability, + state: 'connecting', + details: 'Complete the sign-in flow in the browser opened by the official CLI.', + }; + } + if (!availability.installed) return { ...availability, state: 'unavailable' }; + if (availability.authenticated === true) return { ...availability, state: 'connected' }; + if (availability.authenticated === false) { + return { + ...availability, + state: 'disconnected', + details: this.failed.has(provider) + ? 'Sign-in did not complete. Retry here or use the official CLI in your terminal.' + : 'Sign in with the official CLI to use this provider.', + }; + } + return { ...availability, state: 'unknown' }; + } + + private async probe(provider: ProviderId): Promise> { + const availability = + provider === 'claude' + ? await new ClaudeProvider(this.binaries.claude).checkAvailability() + : await new CodexProvider(this.binaries.codex).checkAvailability(); + return { id: provider, label: LABELS[provider], ...availability }; + } +} + +export function isProviderId(value: string): value is ProviderId { + return (PROVIDER_IDS as readonly string[]).includes(value); +} + +function assertProviderId(value: string): asserts value is ProviderId { + if (!isProviderId(value)) throw new ConfigurationError(`Unsupported provider: ${value}`); +} diff --git a/src/providers/catalog.ts b/src/providers/catalog.ts new file mode 100644 index 0000000..3e1a708 --- /dev/null +++ b/src/providers/catalog.ts @@ -0,0 +1,145 @@ +export const REASONING_EFFORTS = [ + 'auto', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'ultra', +] as const; + +export type ReasoningEffort = (typeof REASONING_EFFORTS)[number]; +export type GuiProviderId = 'claude' | 'codex'; + +export interface ProviderModelOption { + id: string; + label: string; + description: string; + defaultEffort: ReasoningEffort; + efforts: readonly ReasoningEffort[]; +} + +export interface ProviderModelCatalogEntry { + label: string; + defaultModel: string; + models: readonly ProviderModelOption[]; +} + +const CLAUDE_COMMON = ['auto', 'low', 'medium', 'high', 'max'] as const; +const CLAUDE_OPUS = ['auto', 'low', 'medium', 'high', 'xhigh', 'max'] as const; +const CODEX_STANDARD = ['auto', 'low', 'medium', 'high', 'xhigh'] as const; +const CODEX_MAX = ['auto', 'low', 'medium', 'high', 'xhigh', 'max'] as const; +const CODEX_ULTRA = ['auto', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; + +/** + * GUI-safe aliases supported by the official CLIs. Account and workspace policy + * can still restrict which entries are available at execution time. + */ +export const PROVIDER_MODEL_CATALOG: Record = { + claude: { + label: 'Claude Code', + defaultModel: 'default', + models: [ + { + id: 'default', + label: 'Automatic (account default)', + description: 'Uses the recommended Claude model for the signed-in account.', + defaultEffort: 'auto', + efforts: CLAUDE_OPUS, + }, + { + id: 'best', + label: 'Best available', + description: 'Uses the most capable Claude model available to the account.', + defaultEffort: 'xhigh', + efforts: CLAUDE_OPUS, + }, + { + id: 'opus', + label: 'Claude Opus', + description: 'For complex reasoning and high-value implementation work.', + defaultEffort: 'xhigh', + efforts: CLAUDE_OPUS, + }, + { + id: 'sonnet', + label: 'Claude Sonnet', + description: 'For everyday coding, review, and balanced agent work.', + defaultEffort: 'high', + efforts: CLAUDE_COMMON, + }, + { + id: 'haiku', + label: 'Claude Haiku', + description: 'Fast model for small, clearly scoped tasks.', + defaultEffort: 'auto', + efforts: ['auto'], + }, + { + id: 'opusplan', + label: 'Opus plan / Sonnet execute', + description: 'Uses Opus while planning and Sonnet while executing.', + defaultEffort: 'xhigh', + efforts: CLAUDE_OPUS, + }, + ], + }, + codex: { + label: 'OpenAI Codex', + defaultModel: 'gpt-5.6-sol', + models: [ + { + id: 'gpt-5.6-sol', + label: 'GPT-5.6 Sol', + description: 'Detail and polish for complex, open-ended work.', + defaultEffort: 'medium', + efforts: CODEX_ULTRA, + }, + { + id: 'gpt-5.6-terra', + label: 'GPT-5.6 Terra', + description: 'Pragmatic all-rounder for everyday engineering work.', + defaultEffort: 'medium', + efforts: CODEX_ULTRA, + }, + { + id: 'gpt-5.6-luna', + label: 'GPT-5.6 Luna', + description: 'Clear, repeatable, high-volume tasks.', + defaultEffort: 'medium', + efforts: CODEX_MAX, + }, + { + id: 'gpt-5.5', + label: 'GPT-5.5', + description: 'Previous-generation general Codex model.', + defaultEffort: 'medium', + efforts: CODEX_STANDARD, + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + description: 'Compatibility option for workflows pinned to GPT-5.4.', + defaultEffort: 'medium', + efforts: CODEX_STANDARD, + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + description: 'Smaller model for lower-latency scoped tasks.', + defaultEffort: 'medium', + efforts: CODEX_STANDARD, + }, + ], + }, +}; + +export function isProviderSelectionSupported( + provider: string, + model: string, + effort: string, +): boolean { + if (provider !== 'claude' && provider !== 'codex') return false; + const option = PROVIDER_MODEL_CATALOG[provider].models.find((entry) => entry.id === model); + return option?.efforts.includes(effort as ReasoningEffort) ?? false; +} diff --git a/src/providers/claude/adapter.ts b/src/providers/claude/adapter.ts index 4cd652c..9ea76b5 100644 --- a/src/providers/claude/adapter.ts +++ b/src/providers/claude/adapter.ts @@ -36,14 +36,22 @@ export class ClaudeProvider implements AgentProvider { details: 'Claude Code CLI not found. Install it and run `claude` once to sign in.', }; } - // There is no official non-interactive command guaranteed to report auth - // state, so we do not guess (docs/providers.md). + // `claude auth status` is the official non-interactive status probe. Its + // output may contain account metadata, so only the exit code is used. + const auth = await runProcess({ + command: this.binary, + args: ['auth', 'status', '--json'], + cwd: process.cwd(), + timeoutMs: PROBE_TIMEOUT_MS, + }); + const authenticated = auth.exitCode === 0; return { installed: true, version: version.stdout.trim(), - authenticated: undefined, - details: - 'Authentication status cannot be verified automatically; run `claude` once to confirm you are signed in.', + authenticated, + details: authenticated + ? 'Claude Code CLI is installed and logged in.' + : 'Claude Code CLI is installed but not logged in. Run `claude auth login --claudeai`.', }; } @@ -61,6 +69,9 @@ export class ClaudeProvider implements AgentProvider { if (request.model && request.model !== 'default') { args.push('--model', request.model); } + if (request.effort && request.effort !== 'auto') { + args.push('--effort', request.effort); + } const result = await runProcess({ command: this.binary, diff --git a/src/providers/codex/adapter.ts b/src/providers/codex/adapter.ts index 816c60e..07c5c18 100644 --- a/src/providers/codex/adapter.ts +++ b/src/providers/codex/adapter.ts @@ -79,6 +79,9 @@ export class CodexProvider implements AgentProvider { if (request.model && request.model !== 'default') { args.push('--model', request.model); } + if (request.effort && request.effort !== 'auto') { + args.push('--config', `model_reasoning_effort="${request.effort}"`); + } args.push('-'); const result = await runProcess({ diff --git a/src/providers/provider.ts b/src/providers/provider.ts index e6908d2..4c1b4a2 100644 --- a/src/providers/provider.ts +++ b/src/providers/provider.ts @@ -1,4 +1,5 @@ import type { PermissionMode, RoleName } from '../domain/types.js'; +import type { ReasoningEffort } from './catalog.js'; export interface ProviderAvailability { installed: boolean; @@ -23,6 +24,7 @@ export interface AgentRequest { outputSchema: string; timeoutMs: number; model?: string; + effort?: ReasoningEffort; previousHandoff?: unknown; signal?: AbortSignal; } diff --git a/src/roles/common.ts b/src/roles/common.ts index c0d60f8..2edf728 100644 --- a/src/roles/common.ts +++ b/src/roles/common.ts @@ -6,6 +6,7 @@ import { firewallPreamble } from '../security/context-firewall.js'; import { assertRolePermission } from '../security/permissions.js'; import { parseHandoff, type ValidationOutcome } from '../handoff/validator.js'; import type { AgentProvider, AgentRequest, AgentResponse } from '../providers/provider.js'; +import type { Logger } from '../logging/logger.js'; /** Version stamp for all role prompt templates. Bump on breaking prompt changes. */ export const PROMPT_VERSION = 1; @@ -21,6 +22,7 @@ export interface RoleRunOptions { outputSchema: string; timeoutMs: number; signal?: AbortSignal; + logger?: Logger; } export interface RoleRunResult { @@ -49,6 +51,10 @@ export async function runStructuredRole( outputSchema: options.outputSchema, timeoutMs: options.timeoutMs, model: options.roleConfig.model === 'default' ? undefined : options.roleConfig.model, + effort: + options.roleConfig.effort === undefined || options.roleConfig.effort === 'auto' + ? undefined + : options.roleConfig.effort, signal: options.signal, }; diff --git a/src/workflow/orchestrator.ts b/src/workflow/orchestrator.ts index 3c37090..f5f981b 100644 --- a/src/workflow/orchestrator.ts +++ b/src/workflow/orchestrator.ts @@ -286,6 +286,7 @@ function roleOptions( outputSchema, timeoutMs: AGENT_TIMEOUT_MS, signal: options.signal, + logger: options.logger, }; } @@ -369,9 +370,29 @@ async function executeStructuredRole( options: RoleRunOptions, schema: ZodType, ): Promise> { + const startedAt = new Date().toISOString(); + const activity = { + role: options.role, + provider: options.provider.id, + model: options.roleConfig.model, + effort: options.roleConfig.effort ?? 'auto', + startedAt, + } as const; + options.logger?.activity?.({ ...activity, state: 'thinking' }); try { - return await runStructuredRole(options, schema); + const result = await runStructuredRole(options, schema); + options.logger?.activity?.({ + ...activity, + state: 'completed', + finishedAt: new Date().toISOString(), + }); + return result; } catch (error) { + options.logger?.activity?.({ + ...activity, + state: 'failed', + finishedAt: new Date().toISOString(), + }); if (error instanceof ProviderOutputError) { await store.writeText(`raw-output-${artifactLabel}.txt`, error.rawOutput); } diff --git a/tests/integration/gui-server.test.ts b/tests/integration/gui-server.test.ts index 2e285ae..e8af92e 100644 --- a/tests/integration/gui-server.test.ts +++ b/tests/integration/gui-server.test.ts @@ -2,18 +2,19 @@ import { describe, expect, it } from 'vitest'; import { defaultConfig } from '../../src/config/defaults.js'; import { createGuiServer, type GuiServices } from '../../src/gui/server.js'; import type { GuiRunRequest } from '../../src/gui/schema.js'; +import type { ProviderId } from '../../src/providers/auth.js'; const request: GuiRunRequest = { task: 'Preview a safe change', dryRun: true, workflow: { maxCycles: 3, maxRuntimeMinutes: 60, stopOnNoProgress: true }, roles: { - analyst: { provider: 'codex', model: 'default' }, - planner: { provider: 'claude', model: 'default' }, - implementer: { provider: 'codex', model: 'default' }, - reviewer: { provider: 'claude', model: 'default' }, - fixer: { provider: 'codex', model: 'default' }, - final_judge: { provider: 'claude', model: 'default' }, + analyst: { provider: 'codex', model: 'gpt-5.6-terra', effort: 'medium' }, + planner: { provider: 'claude', model: 'sonnet', effort: 'high' }, + implementer: { provider: 'codex', model: 'gpt-5.6-sol', effort: 'ultra' }, + reviewer: { provider: 'claude', model: 'opus', effort: 'xhigh' }, + fixer: { provider: 'codex', model: 'gpt-5.6-sol', effort: 'max' }, + final_judge: { provider: 'claude', model: 'best', effort: 'max' }, }, qualityGates: { requireTestsPass: true, @@ -36,7 +37,9 @@ describe('local GUI server', () => { const page = await fetch(gui.url); expect(page.status).toBe(200); expect(page.headers.get('content-security-policy')).toContain("default-src 'self'"); - expect(await page.text()).toContain('Loop Engineer'); + const pageHtml = await page.text(); + expect(pageHtml).toContain('Loop Engineer'); + expect(pageHtml).toContain('Provider connections'); const bootstrap = (await fetch(`${gui.url}/api/bootstrap`).then((response) => response.json(), @@ -45,6 +48,12 @@ describe('local GUI server', () => { config: { version: number }; }; expect(bootstrap).toMatchObject({ csrfToken: 'test-token', config: { version: 1 } }); + expect(bootstrap).toMatchObject({ + modelCatalog: { + claude: { models: expect.any(Array) }, + codex: { models: expect.any(Array) }, + }, + }); const forbidden = await fetch(`${gui.url}/api/run`, { method: 'POST', @@ -131,10 +140,19 @@ describe('local GUI server', () => { it('cancels an active run, rejects duplicates and reports run failures', async () => { const services = fakeServices(); // A run that only finishes when it is cancelled. - services.run = (_request, signal) => - new Promise((_resolve, reject) => { + services.run = (_request, signal, logger) => { + logger.activity?.({ + state: 'thinking', + role: 'analyst', + provider: 'codex', + model: 'gpt-5.6-terra', + effort: 'medium', + startedAt: new Date(0).toISOString(), + }); + return new Promise((_resolve, reject) => { signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); }); + }; const gui = await createGuiServer({ host: '127.0.0.1', port: 0, @@ -159,6 +177,18 @@ describe('local GUI server', () => { body: JSON.stringify(request), }); expect(started.status).toBe(202); + await new Promise((resolve) => setTimeout(resolve, 0)); + const running = (await fetch(`${gui.url}/api/run`).then((response) => response.json())) as { + active?: { state: string; provider: string; model: string; effort: string }; + }; + expect(running.active).toEqual( + expect.objectContaining({ + state: 'thinking', + provider: 'codex', + model: 'gpt-5.6-terra', + effort: 'medium', + }), + ); const duplicate = await fetch(`${gui.url}/api/run`, { method: 'POST', @@ -180,7 +210,9 @@ describe('local GUI server', () => { // A run that fails immediately surfaces a redacted error snapshot. services.run = async () => { - throw new Error('boom token=supersecret'); + throw new Error( + 'Provider "claude" cannot serve requests right now (role "planner"): You\'ve hit your session limit · resets 12:50pm (Europe/Budapest) token=supersecret', + ); }; const failing = await fetch(`${gui.url}/api/run`, { method: 'POST', @@ -192,26 +224,90 @@ describe('local GUI server', () => { const failed = (await fetch(`${gui.url}/api/run`).then((response) => response.json())) as { status: string; error?: string; + issue?: { kind: string; provider?: string; role?: string; resetAt?: string }; }; expect(failed.status).toBe('failed'); - expect(failed.error).toContain('boom'); + expect(failed.error).toContain('session limit'); expect(failed.error).not.toContain('supersecret'); + expect(failed.issue).toEqual({ + kind: 'session-limit', + provider: 'claude', + role: 'planner', + resetAt: '12:50pm (Europe/Budapest)', + }); + } finally { + await gui.close(); + } + }); + + it('reports provider connections and starts only trusted official CLI login flows', async () => { + const services = fakeServices(); + const gui = await createGuiServer({ + host: '127.0.0.1', + port: 0, + services, + csrfToken: 'token', + }); + const mutationHeaders = { + 'content-type': 'application/json', + origin: gui.url, + 'x-loop-csrf': 'token', + }; + try { + const providers = await fetch(`${gui.url}/api/providers`); + expect(providers.status).toBe(200); + expect(await providers.json()).toEqual([ + expect.objectContaining({ id: 'claude', state: 'disconnected' }), + expect.objectContaining({ id: 'codex', state: 'connected' }), + ]); + + const forbidden = await fetch(`${gui.url}/api/providers/claude/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: gui.url }, + body: '{}', + }); + expect(forbidden.status).toBe(403); + + const started = await fetch(`${gui.url}/api/providers/claude/connect`, { + method: 'POST', + headers: mutationHeaders, + body: '{}', + }); + expect(started.status).toBe(202); + expect(await started.json()).toEqual({ provider: 'claude', status: 'started' }); + expect(services.connectRequests).toEqual(['claude']); + + const unknown = await fetch(`${gui.url}/api/providers/not-a-provider/connect`, { + method: 'POST', + headers: mutationHeaders, + body: '{}', + }); + expect(unknown.status).toBe(404); } finally { await gui.close(); } }); }); -function fakeServices(): GuiServices & { runRequests: GuiRunRequest[] } { +function fakeServices(): GuiServices & { + runRequests: GuiRunRequest[]; + connectRequests: ProviderId[]; +} { const runRequests: GuiRunRequest[] = []; + const connectRequests: ProviderId[] = []; return { runRequests, + connectRequests, async bootstrap() { return { root: '/fixture', config: defaultConfig(), doctor: [{ status: 'pass', label: 'Node.js', detail: 'v20' }], reports: [], + modelCatalog: { + claude: { label: 'Claude Code', defaultModel: 'default', models: [] }, + codex: { label: 'OpenAI Codex', defaultModel: 'gpt-5.6-sol', models: [] }, + }, }; }, async run(runRequest, _signal, logger) { @@ -247,5 +343,29 @@ function fakeServices(): GuiServices & { runRequests: GuiRunRequest[] } { async report() { return '# report'; }, + async providerConnections() { + return [ + { + id: 'claude', + label: 'Claude Code', + installed: true, + authenticated: false, + state: 'disconnected', + details: 'Sign in required.', + }, + { + id: 'codex', + label: 'OpenAI Codex', + installed: true, + authenticated: true, + state: 'connected', + details: 'Connected.', + }, + ]; + }, + async connectProvider(provider) { + connectRequests.push(provider); + return { provider, status: 'started' }; + }, }; } diff --git a/tests/integration/orchestrator.test.ts b/tests/integration/orchestrator.test.ts index d7d0c85..abce196 100644 --- a/tests/integration/orchestrator.test.ts +++ b/tests/integration/orchestrator.test.ts @@ -112,7 +112,20 @@ describe('orchestrator', () => { } const registry: ProviderRegistry = new Map([['fake', fake]]); - const result = await orchestrate({ task: 'Create fixture', repoRoot: root, config, registry }); + const activities: Array<{ state: string; role: string; provider: string }> = []; + const result = await orchestrate({ + task: 'Create fixture', + repoRoot: root, + config, + registry, + logger: { + info() {}, + warn() {}, + activity(activity) { + activities.push(activity); + }, + }, + }); expect(result.report.status).toBe('ready-for-human-review'); expect(result.report.diff.changedFiles).toContain('implemented.txt'); @@ -121,6 +134,12 @@ describe('orchestrator', () => { 'done\n', ); expect(fake.roles).toEqual(['analyst', 'planner', 'implementer', 'reviewer', 'final_judge']); + expect(activities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ state: 'thinking', role: 'analyst', provider: 'fake' }), + expect.objectContaining({ state: 'completed', role: 'analyst', provider: 'fake' }), + ]), + ); }); it('dry-run performs no filesystem or provider changes', async () => { diff --git a/tests/unit/gui-schema.test.ts b/tests/unit/gui-schema.test.ts index 49c3bfb..0aecb20 100644 --- a/tests/unit/gui-schema.test.ts +++ b/tests/unit/gui-schema.test.ts @@ -7,12 +7,12 @@ const validRequest = { dryRun: true, workflow: { maxCycles: 2, maxRuntimeMinutes: 30, stopOnNoProgress: true }, roles: { - analyst: { provider: 'codex', model: 'default' }, - planner: { provider: 'claude', model: 'sonnet' }, - implementer: { provider: 'codex', model: 'default' }, - reviewer: { provider: 'claude', model: 'default' }, - fixer: { provider: 'codex', model: 'default' }, - final_judge: { provider: 'claude', model: 'default' }, + analyst: { provider: 'codex', model: 'gpt-5.6-terra', effort: 'medium' }, + planner: { provider: 'claude', model: 'sonnet', effort: 'high' }, + implementer: { provider: 'codex', model: 'gpt-5.6-sol', effort: 'ultra' }, + reviewer: { provider: 'claude', model: 'opus', effort: 'xhigh' }, + fixer: { provider: 'codex', model: 'gpt-5.6-sol', effort: 'max' }, + final_judge: { provider: 'claude', model: 'best', effort: 'max' }, }, qualityGates: { requireTestsPass: true, @@ -31,6 +31,7 @@ describe('GUI run request', () => { expect(config.roles.planner).toMatchObject({ provider: 'claude', model: 'sonnet', + effort: 'high', permissions: 'read-only', }); expect(config.roles.tester).toMatchObject({ @@ -55,4 +56,25 @@ describe('GUI run request', () => { ).toBe(false); expect(guiRunRequestSchema.safeParse({ ...validRequest, surprise: true }).success).toBe(false); }); + + it('rejects model and intelligence combinations unsupported by the selected provider', () => { + expect( + guiRunRequestSchema.safeParse({ + ...validRequest, + roles: { + ...validRequest.roles, + analyst: { provider: 'claude', model: 'gpt-5.6-sol', effort: 'high' }, + }, + }).success, + ).toBe(false); + expect( + guiRunRequestSchema.safeParse({ + ...validRequest, + roles: { + ...validRequest.roles, + planner: { provider: 'claude', model: 'haiku', effort: 'max' }, + }, + }).success, + ).toBe(false); + }); }); diff --git a/tests/unit/provider-auth.test.ts b/tests/unit/provider-auth.test.ts new file mode 100644 index 0000000..04baa46 --- /dev/null +++ b/tests/unit/provider-auth.test.ts @@ -0,0 +1,106 @@ +import { chmod, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ProviderAuthManager } from '../../src/providers/auth.js'; + +describe('provider authentication manager', () => { + it('delegates sign-in to the official CLIs and only exposes redacted status', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'loopeng-auth-')); + const binary = await fakeAuthCli(root); + const manager = new ProviderAuthManager({ + cwd: root, + claudeBinary: binary, + codexBinary: binary, + timeoutMs: 2_000, + }); + + try { + expect(await manager.connections()).toEqual([ + expect.objectContaining({ + id: 'claude', + installed: true, + authenticated: false, + state: 'disconnected', + }), + expect.objectContaining({ + id: 'codex', + installed: true, + authenticated: false, + state: 'disconnected', + }), + ]); + + await expect(manager.connect('claude')).resolves.toEqual({ + provider: 'claude', + status: 'started', + }); + await expect(manager.connect('codex')).resolves.toEqual({ + provider: 'codex', + status: 'started', + }); + + await waitFor(async () => + (await manager.connections()).every((connection) => connection.authenticated === true), + ); + const connected = await manager.connections(); + expect(connected.every((connection) => connection.state === 'connected')).toBe(true); + expect(JSON.stringify(connected)).not.toContain('oauth-token'); + } finally { + await manager.close(); + } + }); + + it('rejects unknown providers and reports missing CLIs without spawning a shell', async () => { + const manager = new ProviderAuthManager({ + cwd: process.cwd(), + claudeBinary: '/definitely/missing-claude', + codexBinary: '/definitely/missing-codex', + timeoutMs: 200, + }); + + expect((await manager.connections()).every((connection) => !connection.installed)).toBe(true); + await expect(manager.connect('claude')).rejects.toThrow(/not installed/i); + await expect(manager.connect('other' as 'claude')).rejects.toThrow(/Unsupported provider/); + await manager.close(); + }); +}); + +async function fakeAuthCli(root: string): Promise { + const binary = path.join(root, 'fake-auth-cli'); + await writeFile( + binary, + `#!/usr/bin/env node +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const marker = (name) => path.join(__dirname, name + '.connected'); +if (args[0] === '--version') { + console.log('fake-cli 1.0'); +} else if (args[0] === 'auth' && args[1] === 'status') { + if (fs.existsSync(marker('claude'))) console.log(JSON.stringify({ loggedIn: true, secret: 'oauth-token' })); + else process.exitCode = 1; +} else if (args[0] === 'auth' && args[1] === 'login') { + fs.writeFileSync(marker('claude'), 'oauth-token'); +} else if (args[0] === 'login' && args[1] === 'status') { + if (fs.existsSync(marker('codex'))) console.log('Logged in'); + else { console.error('Not logged in'); process.exitCode = 1; } +} else if (args[0] === 'login') { + fs.writeFileSync(marker('codex'), 'oauth-token'); +} else { + process.exitCode = 2; +} +`, + 'utf8', + ); + await chmod(binary, 0o755); + return binary; +} + +async function waitFor(predicate: () => Promise): Promise { + const deadline = Date.now() + 2_000; + while (!(await predicate())) { + if (Date.now() > deadline) throw new Error('Timed out waiting for provider login'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} diff --git a/tests/unit/provider-catalog.test.ts b/tests/unit/provider-catalog.test.ts new file mode 100644 index 0000000..a2157a1 --- /dev/null +++ b/tests/unit/provider-catalog.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + PROVIDER_MODEL_CATALOG, + isProviderSelectionSupported, +} from '../../src/providers/catalog.js'; + +describe('provider model catalog', () => { + it('exposes current provider-specific models and intelligence levels', () => { + expect(PROVIDER_MODEL_CATALOG.codex.models.map((model) => model.id)).toEqual( + expect.arrayContaining(['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna']), + ); + expect( + PROVIDER_MODEL_CATALOG.codex.models.find((model) => model.id === 'gpt-5.6-sol')?.efforts, + ).toContain('ultra'); + expect(PROVIDER_MODEL_CATALOG.claude.models.map((model) => model.id)).toEqual( + expect.arrayContaining(['default', 'best', 'opus', 'sonnet', 'haiku']), + ); + }); + + it('validates model and intelligence combinations per provider', () => { + expect(isProviderSelectionSupported('codex', 'gpt-5.6-terra', 'medium')).toBe(true); + expect(isProviderSelectionSupported('claude', 'opus', 'xhigh')).toBe(true); + expect(isProviderSelectionSupported('claude', 'haiku', 'max')).toBe(false); + expect(isProviderSelectionSupported('claude', 'gpt-5.6-sol', 'high')).toBe(false); + }); +}); diff --git a/tests/unit/utilities-providers.test.ts b/tests/unit/utilities-providers.test.ts index 94862ce..fa944aa 100644 --- a/tests/unit/utilities-providers.test.ts +++ b/tests/unit/utilities-providers.test.ts @@ -201,14 +201,17 @@ describe('provider adapters', () => { role: 'implementer' as const, permissionMode: 'workspace-write' as const, model: 'custom-model', + effort: 'xhigh' as const, }; const claudeWrite = await claude.run(writeRequest); expect(claudeWrite.sanitizedCommand).toContain('--permission-mode acceptEdits'); expect(claudeWrite.sanitizedCommand).toContain('--model custom-model'); + expect(claudeWrite.sanitizedCommand).toContain('--effort xhigh'); expect(claudeWrite.sanitizedCommand).not.toContain('Bash'); const codexWrite = await codex.run(writeRequest); expect(codexWrite.sanitizedCommand).toContain('--sandbox workspace-write'); expect(codexWrite.sanitizedCommand).toContain('--model custom-model'); + expect(codexWrite.sanitizedCommand).toContain('model_reasoning_effort="xhigh"'); expect((await new ClaudeProvider('/definitely/missing').checkAvailability()).installed).toBe( false, );